From 4df3d3554e5767edf6469977972409d3b882cd2e Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Tue, 10 Mar 2026 23:44:05 -0600 Subject: [PATCH 001/279] Enable the address and behavior sanitizers for C++ component unit tests (#13490) --- script/cpp_unit_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c9174584722..c6cfd8270fa 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -79,6 +79,10 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: "-Og", # optimize for debug "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing "-DESPHOME_DEBUG", # enable debug assertions + # Enable the address and undefined behavior sanitizers + "-fsanitize=address", + "-fsanitize=undefined", + "-fno-omit-frame-pointer", ], "debug_build_flags": [ # only for debug builds "-g3", # max debug info From e8e700a683f2ad210892393e6f67260855e493d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 20:51:54 -1000 Subject: [PATCH 002/279] [socket] Fix RP2040 heap corruption from malloc in lwip accept callback (#14687) --- .../components/socket/lwip_raw_tcp_impl.cpp | 142 ++++++++++++++---- esphome/components/socket/lwip_raw_tcp_impl.h | 41 ++--- 2 files changed, 138 insertions(+), 45 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index d7fa6a26945..fd1b8a95542 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -425,20 +425,24 @@ LWIPRawImpl::~LWIPRawImpl() { // Base class destructor handles pcb_ cleanup via tcp_abort } -void LWIPRawImpl::init() { +void LWIPRawImpl::init(struct pbuf *initial_rx, bool initial_rx_closed) { LWIP_LOCK(); LWIP_LOG("init(%p)", this->pcb_); tcp_arg(this->pcb_, this); tcp_recv(this->pcb_, LWIPRawImpl::s_recv_fn); tcp_err(this->pcb_, LWIPRawImpl::s_err_fn); + if (initial_rx != nullptr) { + this->rx_buf_ = initial_rx; + this->rx_buf_offset_ = 0; + } + this->rx_closed_ = initial_rx_closed; } void LWIPRawImpl::s_err_fn(void *arg, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. - // No LWIP_LOCK() needed — acquiring it would be redundant (recursive mutex). + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // No LWIP_LOCK() needed — lwip core already holds the async_context lock. // - // "If a connection is aborted because of an error, the application is alerted of this event by - // the err callback." // pcb is already freed when this callback is called // ERR_RST: connection was reset by remote host // ERR_ABRT: aborted through tcp_abort or TCP timer @@ -453,11 +457,15 @@ err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, er } err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). LWIP_LOG("recv(pb=%p err=%d)", pb, err); if (err != 0) { // "An error code if there has been an error receiving Only return ERR_ABRT if you have // called tcp_abort from within the callback function!" + if (pb != nullptr) { + pbuf_free(pb); + } this->rx_closed_ = true; return ERR_OK; } @@ -664,6 +672,22 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { LWIPRawListenImpl::~LWIPRawListenImpl() { LWIP_LOCK(); + // Abort any queued PCBs that were never accepted by the main loop. + // Clear the error callback first — tcp_abort triggers it, and we don't + // want s_queued_err_fn writing to slots during destruction. + for (uint8_t i = 0; i < this->accepted_socket_count_; i++) { + auto &entry = this->accepted_pcbs_[i]; + if (entry.pcb != nullptr) { + tcp_err(entry.pcb, nullptr); + tcp_abort(entry.pcb); + entry.pcb = nullptr; + } + if (entry.rx_buf != nullptr) { + pbuf_free(entry.rx_buf); + entry.rx_buf = nullptr; + } + } + this->accepted_socket_count_ = 0; // Listen PCBs must use tcp_close(), not tcp_abort(). // tcp_abandon() asserts pcb->state != LISTEN and would access // fields that don't exist in the smaller tcp_pcb_listen struct. @@ -683,12 +707,49 @@ void LWIPRawListenImpl::init() { } void LWIPRawListenImpl::s_err_fn(void *arg, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). auto *arg_this = reinterpret_cast(arg); ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err); arg_this->pcb_ = nullptr; } +void LWIPRawListenImpl::s_queued_err_fn(void *arg, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // Called when a queued (not yet accepted) PCB errors — e.g., remote sent RST. + // The PCB is already freed by lwip. Null our pointer so accept() skips it. + (void) err; + auto *entry = reinterpret_cast(arg); + entry->pcb = nullptr; + // Don't free rx_buf here — accept() will clean it up when it sees pcb==nullptr +} + +err_t LWIPRawListenImpl::s_queued_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // Temporary recv callback for PCBs queued between accept_fn_ and accept(). + // Without this, lwip's default tcp_recv_null handler would ACK and drop the data, + // causing the API handshake to silently fail (client sends Hello, server never sees it). + (void) pcb; + auto *entry = reinterpret_cast(arg); + if (pb == nullptr || err != ERR_OK) { + // Remote closed or error + if (pb != nullptr) { + pbuf_free(pb); + } + entry->rx_closed = true; + return ERR_OK; + } + // Buffer the data — tcp_recved() is deferred to read() after accept() creates the socket. + if (entry->rx_buf == nullptr) { + entry->rx_buf = pb; + } else { + pbuf_cat(entry->rx_buf, pb); + } + return ERR_OK; +} + err_t LWIPRawListenImpl::s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { auto *arg_this = reinterpret_cast(arg); return arg_this->accept_fn_(newpcb, err); @@ -700,23 +761,40 @@ std::unique_ptr LWIPRawListenImpl::accept(struct sockaddr *addr, so errno = EBADF; return nullptr; } - if (this->accepted_socket_count_ == 0) { - errno = EWOULDBLOCK; - return nullptr; + // Dequeue front entry, skipping any null entries (PCBs freed by lwip while queued). + // The error callback nulled their pcb pointers; clean up buffered data and discard. + while (this->accepted_socket_count_ > 0) { + QueuedPcb entry = this->accepted_pcbs_[0]; + // Shift remaining entries forward, updating tcp_arg pointers as we go. + // Safe because we hold LWIP_LOCK, so err/recv callbacks can't fire during the update. + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_pcbs_[i - 1] = this->accepted_pcbs_[i]; + if (this->accepted_pcbs_[i - 1].pcb != nullptr) { + tcp_arg(this->accepted_pcbs_[i - 1].pcb, &this->accepted_pcbs_[i - 1]); + } + } + this->accepted_pcbs_[this->accepted_socket_count_ - 1] = {}; + this->accepted_socket_count_--; + if (entry.pcb == nullptr) { + // PCB was freed by lwip (RST/timeout) while queued — discard and try next + if (entry.rx_buf != nullptr) { + pbuf_free(entry.rx_buf); + } + continue; + } + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); + // Create socket wrapper on the main loop (not in accept callback) to avoid + // heap allocation in IRQ context on RP2040. Transfer any data received while queued. + auto sock = make_unique(this->family_, entry.pcb); + sock->init(entry.rx_buf, entry.rx_closed); + if (addr != nullptr) { + sock->getpeername(addr, addrlen); + } + LWIP_LOG("accept(%p)", sock.get()); + return sock; } - // Take from front for FIFO ordering - std::unique_ptr sock = std::move(this->accepted_sockets_[0]); - // Shift remaining sockets forward - for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { - this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); - } - this->accepted_socket_count_--; - LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); - if (addr != nullptr) { - sock->getpeername(addr, addrlen); - } - LWIP_LOG("accept(%p)", sock.get()); - return sock; + errno = EWOULDBLOCK; + return nullptr; } int LWIPRawListenImpl::listen(int backlog) { @@ -746,7 +824,8 @@ int LWIPRawListenImpl::listen(int backlog) { } err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -763,9 +842,18 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Must return ERR_ABRT since we called tcp_abort() return ERR_ABRT; } - auto sock = make_unique(this->family_, newpcb); - sock->init(); - this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + // Store the raw PCB — LWIPRawImpl creation is deferred to the main-loop accept(). + // This avoids heap allocation in this callback, which is unsafe from IRQ context on RP2040. + uint8_t idx = this->accepted_socket_count_++; + this->accepted_pcbs_[idx] = {newpcb, nullptr, false}; + // Register temporary callbacks so that while the PCB is queued: + // - err: nulls our pointer if the connection errors (RST, timeout) + // - recv: buffers any data that arrives before accept() creates the LWIPRawImpl + // (without this, lwip's default tcp_recv_null would ACK and drop the data) + // tcp_arg points to our queue entry; accept() updates these pointers after shifting. + tcp_arg(newpcb, &this->accepted_pcbs_[idx]); + tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); + tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); #if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can accept the new connection. diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 5b2c11cfe2c..95931afcf3f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -66,7 +66,7 @@ class LWIPRawImpl : public LWIPRawCommon { using LWIPRawCommon::LWIPRawCommon; ~LWIPRawImpl(); - void init(); + void init(struct pbuf *initial_rx = nullptr, bool initial_rx_closed = false); // Non-listening sockets return error std::unique_ptr accept(struct sockaddr *, socklen_t *) { @@ -182,23 +182,28 @@ class LWIPRawListenImpl : public LWIPRawCommon { err_t accept_fn_(struct tcp_pcb *newpcb, err_t err); static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err); - // Accept queue - holds incoming connections briefly until the event loop calls accept() - // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop - // 3 slots is plenty since connections are pulled out quickly by the event loop - // - // Memory analysis: std::array<3> vs original std::queue implementation: - // - std::queue uses std::deque internally which on 32-bit systems needs: - // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations - // Total: ~56+ bytes minimum, plus heap fragmentation - // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) - // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations - // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) - // - // By using a separate listening socket class, regular connected sockets save - // 16 bytes (12 bytes array + 1 byte count + 3 bytes padding) of memory overhead on 32-bit systems - static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; - std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; - uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue + // Temporary callbacks for queued PCBs (between accept_fn_ and accept()) + static void s_queued_err_fn(void *arg, err_t err); + static err_t s_queued_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); + + // Accept queue entry — stores a raw tcp_pcb and any data received while queued. + // lwip's default tcp_recv_null handler drops data and ACKs it, so we must register + // a temporary recv callback to buffer any data that arrives between accept_fn_ + // (which stores the PCB) and accept() (which creates the LWIPRawImpl). + struct QueuedPcb { + struct tcp_pcb *pcb{nullptr}; + struct pbuf *rx_buf{nullptr}; // Data received while queued (before accept() picks it up) + bool rx_closed{false}; // Remote sent FIN while queued + }; + + // Accept queue — stores raw tcp_pcb entries instead of heap-allocated LWIPRawImpl objects. + // LWIPRawImpl creation is deferred to the main-loop accept() call. This avoids: + // - Heap allocation in the accept callback (unsafe from IRQ context on RP2040) + // - Dangling LWIPRawImpl if the connection errors before accept() picks it up + // 2 slots is plenty since the main loop drains the queue every iteration. + static constexpr size_t MAX_ACCEPTED_SOCKETS = 2; + std::array accepted_pcbs_{}; + uint8_t accepted_socket_count_ = 0; // Number of entries currently in queue }; } // namespace esphome::socket From 236f6b1935aec81d6350213e3049c6b0f4fb0794 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Mar 2026 07:52:43 +0100 Subject: [PATCH 003/279] [micronova] Add command queue (#12268) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: edenhaus Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/micronova/__init__.py | 54 +++- .../components/micronova/button/__init__.py | 2 + .../micronova/button/micronova_button.cpp | 10 +- .../micronova/button/micronova_button.h | 11 +- esphome/components/micronova/micronova.cpp | 256 +++++++++++------- esphome/components/micronova/micronova.h | 93 ++++--- .../components/micronova/number/__init__.py | 7 +- .../micronova/number/micronova_number.cpp | 10 +- .../micronova/number/micronova_number.h | 9 +- .../micronova/sensor/micronova_sensor.cpp | 7 + .../micronova/sensor/micronova_sensor.h | 8 +- .../components/micronova/switch/__init__.py | 2 + .../micronova/switch/micronova_switch.cpp | 21 +- .../micronova/switch/micronova_switch.h | 8 +- .../text_sensor/micronova_text_sensor.cpp | 7 + .../text_sensor/micronova_text_sensor.h | 8 +- esphome/core/defines.h | 2 + esphome/core/helpers.h | 75 +++++ 18 files changed, 399 insertions(+), 191 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index d6ef93cf301..b4623522292 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -1,14 +1,34 @@ +from dataclasses import dataclass + from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@jorre05", "@edenhaus"] DEPENDENCIES = ["uart"] DOMAIN = "micronova" + + +@dataclass +class MicronovaData: + """Track micronova component state during code generation.""" + + listener_count: int = 0 + has_writer: bool = False + + +def _get_data() -> MicronovaData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = MicronovaData() + return CORE.data[DOMAIN] + + CONF_MICRONOVA_ID = f"{DOMAIN}_id" CONF_ENABLE_RX_PIN = "enable_rx_pin" CONF_MEMORY_LOCATION = "memory_location" @@ -66,16 +86,42 @@ def MICRONOVA_ADDRESS_SCHEMA( return schema +def register_micronova_writer() -> None: + """Register a component that can write to the stove (button, switch, number).""" + _get_data().has_writer = True + + async def to_code_micronova_listener(mv, var, config): + _get_data().listener_count += 1 await cg.register_component(var, config) - cg.add(mv.register_micronova_listener(var)) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) + # Register listener as last step as we need all properties set before registering + cg.add(mv.register_micronova_listener(var)) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) + var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) await uart.register_uart_device(var, config) - enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - cg.add(var.set_enable_rx_pin(enable_rx_pin)) + CORE.add_job(_final_step) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _final_step() -> None: + """Add defines for listener and writer counts after all are registered.""" + data = _get_data() + if data.listener_count == 0 and not data.has_writer: + raise cv.Invalid( + "No micronova entities configured. Add at least one micronova entity." + ) + if data.listener_count > 255: + raise cv.Invalid( + f"Too many micronova reading entities ({data.listener_count}). Maximum is 255." + ) + if data.listener_count > 0: + cg.add_define("MICRONOVA_LISTENER_COUNT", data.listener_count) + + if data.has_writer: + cg.add_define("USE_MICRONOVA_WRITER") diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 6adf8d96fec..1ef359ea6cf 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -9,6 +9,7 @@ from .. import ( MICRONOVA_ADDRESS_SCHEMA, MicroNova, micronova_ns, + register_micronova_writer, ) MicroNovaButton = micronova_ns.class_("MicroNovaButton", button.Button, cg.Component) @@ -36,6 +37,7 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): + register_micronova_writer() bt = await button.new_button(custom_button_config, mv) cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 3f49d4b5b3b..13c0e1f1176 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -2,9 +2,15 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.button"; + +void MicroNovaButton::dump_config() { + LOG_BUTTON("", "Micronova button", this); + this->dump_base_config(); +} + void MicroNovaButton::press_action() { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); - this->micronova_->request_update_listeners(); + this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 951ae8bba38..0258dbb53c2 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,19 +6,18 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { +class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { public: - MicroNovaButton(MicroNova *m) : MicroNovaButtonListener(m) {} - void dump_config() override { - LOG_BUTTON("", "Micronova button", this); - this->dump_base_config(); - } + MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} + void dump_config() override; void set_memory_data(uint8_t f) { this->memory_data_ = f; } uint8_t get_memory_data() { return this->memory_data_; } protected: void press_action() override; + + uint8_t memory_data_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 22daef4fe6c..c2e3ef86405 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,8 +3,12 @@ namespace esphome::micronova { -static const int STOVE_REPLY_DELAY = 60; -static const uint8_t WRITE_BIT = 1 << 7; // 0x80 +static const char *const TAG = "micronova"; +static constexpr uint8_t STOVE_REPLY_SIZE = 2; +static constexpr uint32_t STOVE_REPLY_TIMEOUT = 200; // ms +static constexpr uint8_t WRITE_BIT = 1 << 7; // 0x80 + +bool MicroNovaCommand::is_write() const { return this->memory_location & WRITE_BIT; } void MicroNovaBaseListener::dump_base_config() { ESP_LOGCONFIG(TAG, @@ -18,139 +22,193 @@ void MicroNovaListener::dump_base_config() { LOG_UPDATE_INTERVAL(this); } +void MicroNovaListener::request_value_from_stove_() { + this->micronova_->queue_read_request(this->memory_location_, this->memory_address_); +} + void MicroNova::setup() { - if (this->enable_rx_pin_ != nullptr) { - this->enable_rx_pin_->setup(); - this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT); - this->enable_rx_pin_->digital_write(false); - } - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = 0; - this->current_transmission_.memory_address = 0; - this->current_transmission_.reply_pending = false; - this->current_transmission_.initiating_listener = nullptr; + this->enable_rx_pin_->setup(); + this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT); + this->enable_rx_pin_->digital_write(false); } void MicroNova::dump_config() { ESP_LOGCONFIG(TAG, "MicroNova:"); - if (this->enable_rx_pin_ != nullptr) { - LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); - } + LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); } -void MicroNova::request_update_listeners() { - ESP_LOGD(TAG, "Schedule listener update"); - for (auto &mv_listener : this->micronova_listeners_) { - mv_listener->set_needs_update(true); +#ifdef MICRONOVA_LISTENER_COUNT +void MicroNova::register_micronova_listener(MicroNovaListener *listener) { + this->listeners_.push_back(listener); + // Request initial value + this->queue_read_request(listener->get_memory_location(), listener->get_memory_address()); +} + +void MicroNova::request_update_listeners_() { + ESP_LOGD(TAG, "Requesting update from all listeners"); + for (auto *listener : this->listeners_) { + this->queue_read_request(listener->get_memory_location(), listener->get_memory_address()); } } +#endif void MicroNova::loop() { - // Only read one sensor that needs update per loop - // If STOVE_REPLY_DELAY time has passed since last loop() - // check for a reply from the stove - if ((this->current_transmission_.reply_pending) && - (millis() - this->current_transmission_.request_transmission_time > STOVE_REPLY_DELAY)) { - int stove_reply_value = this->read_stove_reply(); - if (this->current_transmission_.initiating_listener != nullptr) { - this->current_transmission_.initiating_listener->process_value_from_stove(stove_reply_value); - this->current_transmission_.initiating_listener = nullptr; - } - this->current_transmission_.reply_pending = false; - return; - } else if (!this->current_transmission_.reply_pending) { - for (auto &mv_listener : this->micronova_listeners_) { - if (mv_listener->get_needs_update()) { - mv_listener->set_needs_update(false); - this->current_transmission_.initiating_listener = mv_listener; - mv_listener->request_value_from_stove(); - return; + // Check if we're processing a command and waiting for reply + if (this->reply_pending_) { + // Check if all reply bytes have arrived + if (this->available() >= STOVE_REPLY_SIZE) { +#ifdef MICRONOVA_LISTENER_COUNT + int stove_reply_value = this->read_stove_reply_(); + if (this->current_command_.is_write()) { + if (stove_reply_value == -1) { + ESP_LOGW(TAG, "Write to [0x%02X:0x%02X] may have failed (checksum mismatch in reply)", + this->current_command_.memory_location & ~WRITE_BIT, this->current_command_.memory_address); + } + } else { + // For READ commands, notify all listeners registered for this address + uint8_t loc = this->current_command_.memory_location; + uint8_t addr = this->current_command_.memory_address; + for (auto *listener : this->listeners_) { + if (listener->get_memory_location() == loc && listener->get_memory_address() == addr) { + listener->process_value_from_stove(stove_reply_value); + } + } } +#else + this->read_stove_reply_(); +#endif + this->reply_pending_ = false; + } else if (millis() - this->transmission_time_ > STOVE_REPLY_TIMEOUT) { + // Timeout - no reply received (buffer cleared before next command) + ESP_LOGW(TAG, "Timeout waiting for reply from [0x%02X:0x%02X], available: %d", + this->current_command_.memory_location, this->current_command_.memory_address, this->available()); + this->reply_pending_ = false; } + return; } + + // No reply pending - process next command (writes have priority over reads) +#ifdef USE_MICRONOVA_WRITER + if (!this->write_queue_.empty()) { + this->current_command_ = this->write_queue_.front(); + this->write_queue_.pop(); + this->send_current_command_(); + return; + } +#endif +#ifdef MICRONOVA_LISTENER_COUNT + if (!this->read_queue_.empty()) { + this->current_command_ = this->read_queue_.front(); + this->read_queue_.pop(); + this->send_current_command_(); + } +#endif } -void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaListener *listener) { - uint8_t write_data[2] = {0, 0}; +#ifdef MICRONOVA_LISTENER_COUNT +void MicroNova::queue_read_request(uint8_t location, uint8_t address) { + // Check if this read is already queued + for (const auto &queued : this->read_queue_) { + if (queued.memory_location == location && queued.memory_address == address) { + ESP_LOGV(TAG, "Read [%02X,%02X] already queued, skipping", location, address); + return; + } + } + + MicroNovaCommand cmd; + cmd.memory_location = location; + cmd.memory_address = address; + cmd.data = 0; + + if (!this->read_queue_.push(cmd)) { + ESP_LOGW(TAG, "Read queue full, dropping read [%02X,%02X]", location, address); + return; + } + ESP_LOGV(TAG, "Queued read [%02X,%02X] (queue size: %u)", location, address, this->read_queue_.size()); +} +#endif + +void MicroNova::send_current_command_() { uint8_t trash_rx; - if (this->reply_pending_mutex_.try_lock()) { - // clear rx buffer. - // Stove hickups may cause late replies in the rx - while (this->available()) { - this->read_byte(&trash_rx); - ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx); - } - - write_data[0] = location; - write_data[1] = address; - ESP_LOGV(TAG, "Request from stove [%02X,%02X]", write_data[0], write_data[1]); - - this->enable_rx_pin_->digital_write(true); - this->write_array(write_data, 2); - this->flush(); - this->enable_rx_pin_->digital_write(false); - - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = location; - this->current_transmission_.memory_address = address; - this->current_transmission_.reply_pending = true; - this->current_transmission_.initiating_listener = listener; - } else { - ESP_LOGE(TAG, "Reply is pending, skipping read request"); + // Clear rx buffer - stove hiccups may cause late replies in the rx + while (this->available()) { + this->read_byte(&trash_rx); + ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx); } + + uint8_t write_data[4] = {this->current_command_.memory_location, this->current_command_.memory_address, 0, 0}; + size_t write_len; + + if (this->current_command_.is_write()) { + write_len = 4; + write_data[2] = this->current_command_.data; + // calculate checksum + write_data[3] = write_data[0] + write_data[1] + write_data[2]; + ESP_LOGV(TAG, "Sending write request [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], + write_data[3]); + } else { + write_len = 2; + ESP_LOGV(TAG, "Sending read request [%02X,%02X]", write_data[0], write_data[1]); + } + + this->enable_rx_pin_->digital_write(true); + this->write_array(write_data, write_len); + this->flush(); + this->enable_rx_pin_->digital_write(false); + + this->transmission_time_ = millis(); + this->reply_pending_ = true; } -int MicroNova::read_stove_reply() { +int MicroNova::read_stove_reply_() { uint8_t reply_data[2] = {0, 0}; - uint8_t checksum = 0; - // assert enable_rx_pin is false this->read_array(reply_data, 2); - this->reply_pending_mutex_.unlock(); ESP_LOGV(TAG, "Reply from stove [%02X,%02X]", reply_data[0], reply_data[1]); - checksum = ((uint16_t) this->current_transmission_.memory_location + - (uint16_t) this->current_transmission_.memory_address + (uint16_t) reply_data[1]) & - 0xFF; + uint8_t checksum = this->current_command_.memory_location + this->current_command_.memory_address + reply_data[1]; if (reply_data[0] != checksum) { - ESP_LOGE(TAG, "Checksum missmatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X", - this->current_transmission_.memory_location, this->current_transmission_.memory_address, reply_data[0], + ESP_LOGE(TAG, "Checksum mismatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X", + this->current_command_.memory_location, this->current_command_.memory_address, reply_data[0], reply_data[1], checksum, reply_data[0]); return -1; } return ((int) reply_data[1]); } -void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { - uint8_t write_data[4] = {0, 0, 0, 0}; - uint16_t checksum = 0; +#ifdef USE_MICRONOVA_WRITER +bool MicroNova::queue_write_command(uint8_t location, uint8_t address, uint8_t data) { + MicroNovaCommand cmd; + cmd.memory_location = location | WRITE_BIT; + cmd.memory_address = address; + cmd.data = data; - if (this->reply_pending_mutex_.try_lock()) { - uint8_t write_location = location | WRITE_BIT; - write_data[0] = write_location; - write_data[1] = address; - write_data[2] = data; - - checksum = ((uint16_t) write_data[0] + (uint16_t) write_data[1] + (uint16_t) write_data[2]) & 0xFF; - write_data[3] = checksum; - - ESP_LOGV(TAG, "Write 4 bytes [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], write_data[3]); - - this->enable_rx_pin_->digital_write(true); - this->write_array(write_data, 4); - this->flush(); - this->enable_rx_pin_->digital_write(false); - - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = write_location; - this->current_transmission_.memory_address = address; - this->current_transmission_.reply_pending = true; - this->current_transmission_.initiating_listener = nullptr; - } else { - ESP_LOGE(TAG, "Reply is pending, skipping write"); + // Check if a write to the same address is already queued - update data in-place + for (auto &queued : this->write_queue_) { + if (queued.memory_location == cmd.memory_location && queued.memory_address == cmd.memory_address) { + if (queued.data != cmd.data) { + ESP_LOGD(TAG, "Updating queued write [%02X,%02X] data 0x%02X -> 0x%02X", location, address, queued.data, data); + queued.data = cmd.data; + } else { + ESP_LOGV(TAG, "Write [%02X,%02X] with data 0x%02X already queued, skipping", location, address, data); + } + return true; + } } + + if (!this->write_queue_.push(cmd)) { + ESP_LOGW(TAG, "Write queue full, dropping command"); + return false; + } + ESP_LOGD(TAG, "Queued write [%02X,%02X] (queue size: %u)", location, address, this->write_queue_.size()); +#ifdef MICRONOVA_LISTENER_COUNT + // Automatically queue sensor updates after write commands + this->request_update_listeners_(); +#endif + return true; } +#endif } // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index a70f355ead4..58cca30b836 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -6,11 +6,19 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include - namespace esphome::micronova { -static const char *const TAG = "micronova"; +static constexpr uint8_t WRITE_QUEUE_SIZE = 10; + +/// Represents a command to be sent to the stove +/// Write commands have the high bit (0x80) set in memory_location +struct MicroNovaCommand { + uint8_t memory_location; + uint8_t memory_address; + uint8_t data; ///< Only used for write commands + + bool is_write() const; +}; class MicroNova; @@ -18,11 +26,8 @@ class MicroNova; // Interface classes. class MicroNovaBaseListener { public: - MicroNovaBaseListener() {} MicroNovaBaseListener(MicroNova *m) { this->micronova_ = m; } - void set_micronova_object(MicroNova *m) { this->micronova_ = m; } - void set_memory_location(uint8_t l) { this->memory_location_ = l; } uint8_t get_memory_location() { return this->memory_location_; } @@ -32,70 +37,76 @@ class MicroNovaBaseListener { void dump_base_config(); protected: - MicroNova *micronova_{nullptr}; + MicroNova *micronova_; uint8_t memory_location_ = 0; uint8_t memory_address_ = 0; }; class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent { public: - MicroNovaListener() {} MicroNovaListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void request_value_from_stove() = 0; + + void update() override { this->request_value_from_stove_(); } + virtual void process_value_from_stove(int value_from_stove) = 0; - void set_needs_update(bool u) { this->needs_update_ = u; } - bool get_needs_update() { return this->needs_update_; } - - void update() override { this->set_needs_update(true); } - void dump_base_config(); protected: - bool needs_update_ = false; -}; - -class MicroNovaButtonListener : public MicroNovaBaseListener { - public: - MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {} - - protected: - uint8_t memory_data_ = 0; + void request_value_from_stove_(); }; ///////////////////////////////////////////////////////////////////// // Main component class class MicroNova : public Component, public uart::UARTDevice { public: - MicroNova() {} + MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} void setup() override; void loop() override; void dump_config() override; - void register_micronova_listener(MicroNovaListener *l) { this->micronova_listeners_.push_back(l); } - void request_update_listeners(); - void request_address(uint8_t location, uint8_t address, MicroNovaListener *listener); - void write_address(uint8_t location, uint8_t address, uint8_t data); - int read_stove_reply(); +#ifdef MICRONOVA_LISTENER_COUNT + void register_micronova_listener(MicroNovaListener *listener); - void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; } + /// Queue a read request to the stove (low priority - added at back) + /// All listeners registered for this address will be notified with the result + /// @param location Memory location on the stove + /// @param address Memory address on the stove + void queue_read_request(uint8_t location, uint8_t address); +#endif + +#ifdef USE_MICRONOVA_WRITER + /// Queue a write command to the stove (processed before reads) + /// @param location Memory location on the stove + /// @param address Memory address on the stove + /// @param data Data to write + /// @return true if command was queued, false if queue was full + bool queue_write_command(uint8_t location, uint8_t address, uint8_t data); +#endif protected: - GPIOPin *enable_rx_pin_{nullptr}; + void send_current_command_(); + int read_stove_reply_(); +#ifdef MICRONOVA_LISTENER_COUNT + void request_update_listeners_(); +#endif - struct MicroNovaSerialTransmission { - uint32_t request_transmission_time; - uint8_t memory_location; - uint8_t memory_address; - bool reply_pending; - MicroNovaListener *initiating_listener; - }; + GPIOPin *enable_rx_pin_; - Mutex reply_pending_mutex_; - MicroNovaSerialTransmission current_transmission_; +#ifdef USE_MICRONOVA_WRITER + StaticRingBuffer write_queue_; +#endif +#ifdef MICRONOVA_LISTENER_COUNT + StaticRingBuffer read_queue_; +#endif + MicroNovaCommand current_command_{}; + uint32_t transmission_time_{0}; ///< Time when current command was sent + bool reply_pending_{false}; ///< True if we are waiting for a reply from the stove - std::vector micronova_listeners_{}; +#ifdef MICRONOVA_LISTENER_COUNT + StaticVector listeners_; +#endif }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index ef6cc0f7d7c..bcc972c5a9d 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -9,6 +9,7 @@ from .. import ( MicroNova, MicroNovaListener, micronova_ns, + register_micronova_writer, to_code_micronova_listener, ) @@ -59,22 +60,24 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): + register_micronova_writer() numb = await number.new_number( thermostat_temperature_config, + mv, min_value=0, max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) await to_code_micronova_listener(mv, numb, thermostat_temperature_config) - cg.add(numb.set_micronova_object(mv)) cg.add(numb.set_use_step_scaling(True)) if power_level_config := config.get(CONF_POWER_LEVEL): + register_micronova_writer() numb = await number.new_number( power_level_config, + mv, min_value=1, max_value=5, step=1, ) await to_code_micronova_listener(mv, numb, power_level_config) - cg.add(numb.set_micronova_object(mv)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 80279474689..02a6de1c844 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.number"; + +void MicroNovaNumber::dump_config() { + LOG_NUMBER("", "Micronova number", this); + this->dump_base_config(); +} + void MicroNovaNumber::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state(NAN); @@ -22,8 +29,7 @@ void MicroNovaNumber::control(float value) { } else { new_number = static_cast(value); } - this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); - this->micronova_->request_update_listeners(); + this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, new_number); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 3fc5838a4fc..73666b632bf 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -7,16 +7,9 @@ namespace esphome::micronova { class MicroNovaNumber : public number::Number, public MicroNovaListener { public: - MicroNovaNumber() {} MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_NUMBER("", "Micronova number", this); - this->dump_base_config(); - } + void dump_config() override; void control(float value) override; - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } void process_value_from_stove(int value_from_stove) override; void set_use_step_scaling(bool v) { this->use_step_scaling_ = v; } diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index d845e0ab3cb..8d528145cd2 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.sensor"; + +void MicroNovaSensor::dump_config() { + LOG_SENSOR("", "Micronova sensor", this); + this->dump_base_config(); +} + void MicroNovaSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state(NAN); diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index a2f232c7dc5..f3b06d140e4 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -8,14 +8,8 @@ namespace esphome::micronova { class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_SENSOR("", "Micronova sensor", this); - this->dump_base_config(); - } + void dump_config() override; - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } void process_value_from_stove(int value_from_stove) override; void set_divisor(uint8_t d) { this->divisor_ = d; } diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index c937a4cac9e..d9722b5d488 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -9,6 +9,7 @@ from .. import ( MicroNova, MicroNovaListener, micronova_ns, + register_micronova_writer, to_code_micronova_listener, ) @@ -48,6 +49,7 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): + register_micronova_writer() sw = await switch.new_switch(stove_config, mv) await to_code_micronova_listener(mv, sw, stove_config) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 9b9ad610183..d01cc572543 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -2,33 +2,42 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.switch"; + +void MicroNovaSwitch::dump_config() { + LOG_SWITCH("", "Micronova switch", this); + this->dump_base_config(); +} + void MicroNovaSwitch::write_state(bool state) { if (state) { // Only send power-on when current state is Off if (this->raw_state_ == 0) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); - this->publish_state(true); + if (this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_on_)) { + this->publish_state(true); + } } else { ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); } } else { // don't send power-off when status is Off or Final cleaning if (this->raw_state_ != 0 && this->raw_state_ != 6) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); - this->publish_state(false); + if (this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, + this->memory_data_off_)) { + this->publish_state(false); + } } else { ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } } - this->set_needs_update(true); } void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { - this->raw_state_ = value_from_stove; if (value_from_stove == -1) { ESP_LOGE(TAG, "Error reading stove state"); return; } + this->raw_state_ = value_from_stove; // set the stove switch to on for any value but 0 bool state = value_from_stove != 0; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index 96c2c14e9ef..fee3c739769 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -9,13 +9,7 @@ namespace esphome::micronova { class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_SWITCH("", "Micronova switch", this); - this->dump_base_config(); - } - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } + void dump_config() override; void process_value_from_stove(int value_from_stove) override; void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; } diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 2217ed6d6f1..50a0d34b3a7 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.text_sensor"; + +void MicroNovaTextSensor::dump_config() { + LOG_TEXT_SENSOR("", "Micronova text sensor", this); + this->dump_base_config(); +} + void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state("unknown"); diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 290f0ca45a7..6918a372e86 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -20,13 +20,7 @@ static const char *const STOVE_STATES[11] = {"Off", class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_TEXT_SENSOR("", "Micronova text sensor", this); - this->dump_base_config(); - } - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } + void dump_config() override; void process_value_from_stove(int value_from_stove) override; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 44918fe00c2..cec77fe2e27 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -107,6 +107,8 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define MICRONOVA_LISTENER_COUNT 1 +#define USE_MICRONOVA_WRITER #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 11e0afe5260..70ac1574f0c 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -293,6 +293,81 @@ template class StaticVector { operator std::span() const { return std::span(data_.data(), count_); } }; +/// Fixed-size circular buffer with FIFO semantics and iteration support. +/// +/// A tiny ring buffer that avoids dynamic allocations from std::deque/std::queue +/// (which can be wasteful on MCUs), while supporting iteration over queued elements. +/// +/// Not thread-safe. All access (push/pop/iteration) must occur from a single +/// context, or the caller must provide external synchronization. +template class StaticRingBuffer { + using index_type = std::conditional_t<(N <= 255), uint8_t, uint16_t>; + + public: + class Iterator { + public: + Iterator(StaticRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + T &operator*() { return buf_->data_[(buf_->head_ + pos_) % N]; } + Iterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const Iterator &other) const { return pos_ != other.pos_; } + + private: + StaticRingBuffer *buf_; + index_type pos_; + }; + + class ConstIterator { + public: + ConstIterator(const StaticRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + const T &operator*() const { return buf_->data_[(buf_->head_ + pos_) % N]; } + ConstIterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const ConstIterator &other) const { return pos_ != other.pos_; } + + private: + const StaticRingBuffer *buf_; + index_type pos_; + }; + + bool push(const T &value) { + if (this->count_ >= N) { + return false; + } + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % N; + ++this->count_; + return true; + } + + void pop() { + if (this->count_ > 0) { + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + } + + T &front() { return this->data_[this->head_]; } + const T &front() const { return this->data_[this->head_]; } + index_type size() const { return this->count_; } + bool empty() const { return this->count_ == 0; } + + Iterator begin() { return Iterator(this, 0); } + Iterator end() { return Iterator(this, this->count_); } + ConstIterator begin() const { return ConstIterator(this, 0); } + ConstIterator end() const { return ConstIterator(this, this->count_); } + + protected: + T data_[N]; + index_type head_{0}; + index_type tail_{0}; + index_type count_{0}; +}; + /// Fixed-capacity vector - allocates once at runtime, never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time From c52a48ed38923eccf0d5c38a70650d34e17a4f22 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:11:46 -0400 Subject: [PATCH 004/279] [multiple] Convert static function locals to member variables (#14689) Co-authored-by: Claude Opus 4.6 Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/daikin_arc/daikin_arc.cpp | 5 ++--- esphome/components/daikin_arc/daikin_arc.h | 1 + esphome/components/haier/hon_climate.cpp | 5 ++--- esphome/components/haier/hon_climate.h | 1 + .../components/ina2xx_base/ina2xx_base.cpp | 5 ++--- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 21 +++++++++---------- esphome/components/ltr_als_ps/ltr_als_ps.h | 5 ++++- .../matrix_keypad/matrix_keypad.cpp | 12 +++++------ .../components/matrix_keypad/matrix_keypad.h | 2 ++ .../components/mqtt/mqtt_backend_esp32.cpp | 9 ++++---- esphome/components/mqtt/mqtt_backend_esp32.h | 1 + esphome/components/sgp4x/sgp4x.cpp | 5 +++-- esphome/components/sgp4x/sgp4x.h | 3 ++- 13 files changed, 39 insertions(+), 36 deletions(-) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index adb7b9fec76..18f12dbfc6c 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -91,11 +91,10 @@ void DaikinArcClimate::transmit_state() { remote_state[5] = this->operation_mode_() | 0x08; remote_state[6] = this->temperature_(); remote_state[7] = this->humidity_(); - static uint8_t last_humidity = 0x66; - if (remote_state[7] != last_humidity && this->mode != climate::CLIMATE_MODE_OFF) { + if (remote_state[7] != this->last_humidity_ && this->mode != climate::CLIMATE_MODE_OFF) { ESP_LOGD(TAG, "Set Humditiy: %d, %d\n", (int) this->target_humidity, (int) remote_state[7]); remote_header[9] |= 0x10; - last_humidity = remote_state[7]; + this->last_humidity_ = remote_state[7]; } uint16_t fan_speed = this->fan_speed_(); remote_state[8] = fan_speed >> 8; diff --git a/esphome/components/daikin_arc/daikin_arc.h b/esphome/components/daikin_arc/daikin_arc.h index 6cfffd47255..2b4d4375aae 100644 --- a/esphome/components/daikin_arc/daikin_arc.h +++ b/esphome/components/daikin_arc/daikin_arc.h @@ -70,6 +70,7 @@ class DaikinArcClimate : public climate_ir::ClimateIR { // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + uint8_t last_humidity_{0x66}; }; } // namespace daikin_arc diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index be5035caa17..b8889ef2bdc 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -1375,9 +1375,8 @@ void HonClimate::process_protocol_reset() { bool HonClimate::should_get_big_data_() { if (this->big_data_sensors_ > 0) { - static uint8_t counter = 0; - counter = (counter + 1) % 3; - return counter == 1; + this->big_data_counter_ = (this->big_data_counter_ + 1) % 3; + return this->big_data_counter_ == 1; } return false; } diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 4565ed2981d..9bddac3f923 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -188,6 +188,7 @@ class HonClimate : public HaierClimateBase { float active_alarm_count_{NAN}; std::chrono::steady_clock::time_point last_alarm_request_; int big_data_sensors_{0}; + uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 9f510eef74f..2d08562e542 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -572,9 +572,8 @@ bool INA2XX::write_unsigned_16_(uint8_t reg, uint16_t val) { } bool INA2XX::read_unsigned_(uint8_t reg, uint8_t reg_size, uint64_t &data_out) { - static uint8_t rx_buf[5] = {0}; // max buffer size - - if (reg_size > 5) { + uint8_t rx_buf[5]{}; + if (reg_size > sizeof(rx_buf)) { return false; } diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index f9c1474c85a..ff335fe34c6 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -137,7 +137,6 @@ void LTRAlsPsComponent::update() { void LTRAlsPsComponent::loop() { ErrorCode err = i2c::ERROR_OK; - static uint8_t tries{0}; switch (this->state_) { case State::DELAYED_SETUP: @@ -166,20 +165,20 @@ void LTRAlsPsComponent::loop() { case State::WAITING_FOR_DATA: if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { - tries = 0; + this->read_data_tries_ = 0; ESP_LOGV(TAG, "Reading sensor data having gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); this->read_sensor_data_(this->als_readings_); this->state_ = State::DATA_COLLECTED; this->apply_lux_calculation_(this->als_readings_); - } else if (tries >= MAX_TRIES) { + } else if (this->read_data_tries_ >= MAX_TRIES) { ESP_LOGW(TAG, "Can't get data after several tries."); - tries = 0; + this->read_data_tries_ = 0; this->status_set_warning(); this->state_ = State::IDLE; return; } else { - tries++; + this->read_data_tries_++; } break; @@ -221,21 +220,21 @@ void LTRAlsPsComponent::loop() { } void LTRAlsPsComponent::check_and_trigger_ps_() { - static uint32_t last_high_trigger_time{0}; - static uint32_t last_low_trigger_time{0}; uint16_t ps_data = this->read_ps_data_(); uint32_t now = millis(); if (ps_data != this->ps_readings_) { this->ps_readings_ = ps_data; // Higher values - object is closer to sensor - if (ps_data > this->ps_threshold_high_ && now - last_high_trigger_time >= this->ps_cooldown_time_s_ * 1000) { - last_high_trigger_time = now; + if (ps_data > this->ps_threshold_high_ && + now - this->last_ps_high_trigger_time_ >= this->ps_cooldown_time_s_ * 1000) { + this->last_ps_high_trigger_time_ = now; ESP_LOGV(TAG, "Proximity high threshold triggered. Value = %d, Trigger level = %d", ps_data, this->ps_threshold_high_); this->on_ps_high_trigger_callback_.call(); - } else if (ps_data < this->ps_threshold_low_ && now - last_low_trigger_time >= this->ps_cooldown_time_s_ * 1000) { - last_low_trigger_time = now; + } else if (ps_data < this->ps_threshold_low_ && + now - this->last_ps_low_trigger_time_ >= this->ps_cooldown_time_s_ * 1000) { + this->last_ps_low_trigger_time_ = now; ESP_LOGV(TAG, "Proximity low threshold triggered. Value = %d, Trigger level = %d", ps_data, this->ps_threshold_low_); this->on_ps_low_trigger_callback_.call(); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index c6052300de8..3ab2cea074f 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -126,10 +126,13 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { MeasurementRepeatRate repeat_rate_{MeasurementRepeatRate::REPEAT_RATE_500MS}; float glass_attenuation_factor_{1.0}; + uint32_t last_ps_high_trigger_time_{0}; + uint32_t last_ps_low_trigger_time_{0}; uint16_t ps_cooldown_time_s_{5}; - PsGain ps_gain_{PsGain::PS_GAIN_16}; uint16_t ps_threshold_high_{0xffff}; uint16_t ps_threshold_low_{0x0000}; + uint8_t read_data_tries_{0}; + PsGain ps_gain_{PsGain::PS_GAIN_16}; // // Sensors for publishing data diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index febbe794e47..cc46ba98d63 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -27,8 +27,6 @@ void MatrixKeypad::setup() { } void MatrixKeypad::loop() { - static uint32_t active_start = 0; - static int active_key = -1; uint32_t now = App.get_loop_component_start_time(); int key = -1; bool error = false; @@ -54,8 +52,8 @@ void MatrixKeypad::loop() { if (error) return; - if (key != active_key) { - if ((active_key != -1) && (this->pressed_key_ == active_key)) { + if (key != this->active_key_) { + if ((this->active_key_ != -1) && (this->pressed_key_ == this->active_key_)) { row = this->pressed_key_ / this->columns_.size(); col = this->pressed_key_ % this->columns_.size(); ESP_LOGD(TAG, "key @ row %d, col %d released", row, col); @@ -70,13 +68,13 @@ void MatrixKeypad::loop() { this->pressed_key_ = -1; } - active_key = key; + this->active_key_ = key; if (key == -1) return; - active_start = now; + this->active_start_ = now; } - if ((this->pressed_key_ == key) || (now - active_start < this->debounce_time_)) + if ((this->pressed_key_ == key) || (now - this->active_start_ < this->debounce_time_)) return; row = key / this->columns_.size(); diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 258ab4fadc8..8963612d0c7 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -44,6 +44,8 @@ class MatrixKeypad : public key_provider::KeyProvider, public Component { bool has_diodes_{false}; bool has_pulldowns_{false}; int pressed_key_ = -1; + uint32_t active_start_{0}; + int active_key_{-1}; std::vector listeners_{}; std::vector key_triggers_; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 8a7fb965e96..5642fd5f7b6 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -150,17 +150,16 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { this->on_publish_.call((int) event.msg_id); break; case MQTT_EVENT_DATA: { - static std::string topic; if (!event.topic.empty()) { // When a single message arrives as multiple chunks, the topic will be empty // on any but the first message, leading to event.topic being an empty string. // To ensure handlers get the correct topic, cache the last seen topic to // simulate always receiving the topic from underlying library - topic = event.topic; + this->cached_topic_ = event.topic; } - ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", topic.c_str()); - this->on_message_.call(topic.c_str(), event.data.data(), event.data.size(), event.current_data_offset, - event.total_data_len); + ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", this->cached_topic_.c_str()); + this->on_message_.call(this->cached_topic_.c_str(), event.data.data(), event.data.size(), + event.current_data_offset, event.total_data_len); } break; case MQTT_EVENT_ERROR: ESP_LOGE(TAG, "MQTT_EVENT_ERROR"); diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index ccc4c4026c7..5c4dc413bdc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -265,6 +265,7 @@ class MQTTBackendESP32 final : public MQTTBackend { CallbackManager on_unsubscribe_; CallbackManager on_message_; CallbackManager on_publish_; + std::string cached_topic_; std::queue mqtt_events_; #if defined(USE_MQTT_IDF_ENQUEUE) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index cb41e374f82..bbf1ffd4c09 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -124,6 +124,7 @@ void SGP4xComponent::self_test_() { } this->self_test_complete_ = true; + this->nox_conditioning_start_ = millis(); ESP_LOGD(TAG, "Self-test complete"); }); } @@ -161,7 +162,6 @@ void SGP4xComponent::update_gas_indices_() { void SGP4xComponent::measure_raw_() { float humidity = NAN; - static uint32_t nox_conditioning_start = millis(); if (!this->self_test_complete_) { ESP_LOGW(TAG, "Self-test incomplete"); @@ -191,10 +191,11 @@ void SGP4xComponent::measure_raw_() { response_words = 1; } else { // SGP41 sensor must use NOx conditioning command for the first 10 seconds - if (millis() - nox_conditioning_start < 10000) { + if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { command = SGP41_CMD_NOX_CONDITIONING; response_words = 1; } else { + this->nox_conditioning_start_.reset(); command = SGP41_CMD_MEASURE_RAW; response_words = 2; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 89fa627c61c..6b8b598aff3 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -127,8 +127,9 @@ class SGP4xComponent : public PollingComponent, public sensor::Sensor, public se uint16_t measure_time_; uint8_t samples_read_ = 0; uint8_t samples_to_stabilize_ = static_cast(GasIndexAlgorithm_INITIAL_BLACKOUT) * 2; - bool store_baseline_; + + optional nox_conditioning_start_{}; ESPPreferenceObject pref_; uint32_t seconds_since_last_store_; SGP4xBaselines voc_baselines_storage_; From 4e16f270a3928738460139426748c3d96dafdddd Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 11 Mar 2026 12:47:58 -0500 Subject: [PATCH 005/279] [speaker_source] Add playlist management (#14652) --- .../components/speaker_source/automation.h | 29 ++ .../components/speaker_source/media_player.py | 43 ++ .../speaker_source_media_player.cpp | 401 ++++++++++++++---- .../speaker_source_media_player.h | 46 +- tests/components/speaker_source/common.yaml | 5 + 5 files changed, 429 insertions(+), 95 deletions(-) create mode 100644 esphome/components/speaker_source/automation.h diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h new file mode 100644 index 00000000000..b436149a03f --- /dev/null +++ b/esphome/components/speaker_source/automation.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "speaker_source_media_player.h" + +namespace esphome::speaker_source { + +template class SetPlaylistDelayAction : public Action { + public: + explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(uint8_t, pipeline) + TEMPLATABLE_VALUE(uint32_t, delay) + + void play(const Ts &...x) override { + this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...)); + } + + protected: + SpeakerSourceMediaPlayer *parent_; +}; + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index a44cdcbf01e..9080bebcae0 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -3,13 +3,16 @@ import esphome.codegen as cg from esphome.components import audio, media_player, media_source, speaker import esphome.config_validation as cv from esphome.const import ( + CONF_DELAY, CONF_FORMAT, CONF_ID, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, CONF_SPEAKER, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -19,6 +22,7 @@ CODEOWNERS = ["@kahrendt"] CONF_MEDIA_PIPELINE = "media_pipeline" CONF_ON_MUTE = "on_mute" +CONF_PIPELINE = "pipeline" CONF_ON_UNMUTE = "on_unmute" CONF_ON_VOLUME = "on_volume" CONF_SOURCES = "sources" @@ -36,6 +40,13 @@ SpeakerSourceMediaPlayer = speaker_source_ns.class_( PipelineContext = speaker_source_ns.struct("PipelineContext") Pipeline = speaker_source_ns.enum("Pipeline") +PIPELINE_ENUM = { + "media": Pipeline.MEDIA_PIPELINE, +} + +SetPlaylistDelayAction = speaker_source_ns.class_( + "SetPlaylistDelayAction", automation.Action +) FORMAT_MAPPING = { @@ -210,3 +221,35 @@ async def to_code(config: ConfigType) -> None: [(cg.float_, "x")], on_volume, ) + + +SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(SpeakerSourceMediaPlayer), + cv.Required(CONF_PIPELINE): cv.enum(PIPELINE_ENUM, lower=True), + cv.Required(CONF_DELAY): cv.templatable(cv.positive_time_period_milliseconds), + } +) + + +@automation.register_action( + "speaker_source.set_playlist_delay", + SetPlaylistDelayAction, + SET_PLAYLIST_DELAY_ACTION_SCHEMA, + synchronous=True, +) +async def set_playlist_delay_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + + cg.add(var.set_pipeline(config[CONF_PIPELINE])) + + template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) + cg.add(var.set_delay(template_)) + + return var diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index a3679891d2d..3724e206673 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -135,8 +135,12 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med PipelineContext &ps = this->pipelines_[pipeline]; if (state == media_source::MediaSourceState::IDLE) { + // Track whether this IDLE was from an orchestrator-initiated stop (e.g., NEXT/PREV/PLAY_URI) + // so we can suppress spurious PLAYLIST_ADVANCE below + bool was_stopping = (ps.stopping_source == source); + // Source went idle - clear stopping flag if this was the source we asked to stop - if (ps.stopping_source == source) { + if (was_stopping) { ps.stopping_source = nullptr; } @@ -152,6 +156,11 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med // Finish the speaker to ensure it's ready for the next playback ps.speaker->finish(); + + // Only advance the playlist if the track finished naturally (not stopped by the orchestrator) + if (!was_stopping) { + this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline); + } } } else if (state == media_source::MediaSourceState::PLAYING) { // Source started playing - make it the active source if no one else is active @@ -197,8 +206,9 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so return 0; } +// THREAD CONTEXT: Called from main loop (loop) media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_( - media_source::MediaSource *source) const { + media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const { if (source != nullptr) { switch (source->get_state()) { case media_source::MediaSourceState::PLAYING: @@ -214,97 +224,27 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat } } + // No active source. Stay PLAYING during playlist transitions + if (playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_PLAYING) { + return media_player::MEDIA_PLAYER_STATE_PLAYING; + } return media_player::MEDIA_PLAYER_STATE_IDLE; } void SpeakerSourceMediaPlayer::loop() { // Process queued control commands - MediaPlayerControlCommand control_command; - - // Use peek to check command without removing it - if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) == pdTRUE) { - bool command_executed = false; - uint8_t pipeline = control_command.pipeline; - - switch (control_command.type) { - case MediaPlayerControlCommand::PLAY_URI: { - command_executed = this->try_execute_play_uri_(*control_command.data.uri, pipeline); - break; - } - - case MediaPlayerControlCommand::SEND_COMMAND: { - PipelineContext &ps = this->pipelines_[pipeline]; - - // Determine target source: prefer active, fall back to last - media_source::MediaSource *target_source = nullptr; - if (ps.active_source != nullptr) { - target_source = ps.active_source; - } else if (ps.last_source != nullptr) { - target_source = ps.last_source; - } - - media_player::MediaPlayerCommand player_command = control_command.data.command; - switch (player_command) { - case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: { - media_source::MediaSource *active_source = ps.active_source; - if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PAUSE); - } - } else { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PLAY); - } - } - break; - } - - case media_player::MEDIA_PLAYER_COMMAND_PLAY: { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PLAY); - } - break; - } - - case media_player::MEDIA_PLAYER_COMMAND_PAUSE: { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PAUSE); - } - break; - } - - case media_player::MEDIA_PLAYER_COMMAND_STOP: { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::STOP); - } - break; - } - - default: - break; - } - - command_executed = true; - break; - } - } - - // Only remove from queue if successfully executed - if (command_executed) { - xQueueReceive(this->media_control_command_queue_, &control_command, 0); - - // Delete the allocated string for PLAY_URI commands - if (control_command.type == MediaPlayerControlCommand::PLAY_URI) { - delete control_command.data.uri; - } - } - } + this->process_control_queue_(); // Update state based on active sources media_player::MediaPlayerState old_state = this->state; PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE]; - this->state = this->get_media_pipeline_state_(media_ps.active_source); + + // Check playlist state to detect transitions between items + bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) || + (media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty()); + + this->state = this->get_media_pipeline_state_(media_ps.active_source, media_playlist_active, old_state); if (this->state != old_state) { this->publish_state(); @@ -349,9 +289,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin // Only send END command once per source - check if we've already asked this source to stop if (ps.stopping_source != active_source) { ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline); + ps.stopping_source = active_source; active_source->handle_command(media_source::MediaSourceCommand::STOP); ps.speaker->stop(); - ps.stopping_source = active_source; } return false; // Leave in queue, retry next loop } @@ -363,9 +303,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin // Only send STOP command once per source if (ps.stopping_source != target_source) { ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline); + ps.stopping_source = target_source; target_source->handle_command(media_source::MediaSourceCommand::STOP); ps.speaker->stop(); - ps.stopping_source = target_source; } return false; // Leave in queue, retry next loop } @@ -385,6 +325,7 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin if (!target_source->play_uri(uri)) { ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str()); ps.pending_source = nullptr; + this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline); } // Reset pending frame counter for this pipeline since we're starting a new source @@ -393,6 +334,280 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin return true; // Remove from queue } +// THREAD CONTEXT: Called from main loop (process_control_queue_, queue_play_current_, handle_media_state_changed_) +void SpeakerSourceMediaPlayer::queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline) { + MediaPlayerControlCommand cmd{}; + cmd.type = type; + cmd.pipeline = pipeline; + if (xQueueSend(this->media_control_command_queue_, &cmd, 0) != pdTRUE) { + ESP_LOGE(TAG, "Queue full, command dropped"); + } +} + +// THREAD CONTEXT: Called from main loop via automation commands (direct) +void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms) { + if (pipeline < this->pipelines_.size()) { + this->pipelines_[pipeline].playlist_delay_ms = delay_ms; + } +} + +// THREAD CONTEXT: Called from main loop (process_control_queue_). +// The timeout callback also runs on the main loop. +void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) { + if (delay_ms > 0) { + this->set_timeout(PipelineContext::TIMEOUT_IDS[pipeline], delay_ms, + [this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); }); + } else { + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } +} + +// THREAD CONTEXT: Called from main loop (loop) +void SpeakerSourceMediaPlayer::process_control_queue_() { + MediaPlayerControlCommand control_command; + + // Use peek to check command without removing it + if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + return; + } + + bool command_executed = false; + uint8_t pipeline = control_command.pipeline; + + // Get pipeline state + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *active_source = ps.active_source; + + switch (control_command.type) { + case MediaPlayerControlCommand::PLAY_URI: { + // Always use our local playlist to start playback + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + ps.playlist.clear(); + ps.playlist_index = 0; // Reset index + ps.playlist.push_back(*control_command.data.uri); + + // Queue PLAY_CURRENT to initiate playback + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + command_executed = true; + break; + } + + case MediaPlayerControlCommand::ENQUEUE_URI: { + // Always add to our local playlist + ps.playlist.push_back(*control_command.data.uri); + + // If nothing is playing and no upcoming items are queued, start the new item. + bool nothing_playing = + (active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE); + if (nothing_playing && ps.playlist_index >= ps.playlist.size() - 1) { + ps.playlist_index = ps.playlist.size() - 1; // Point to newly added item + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + command_executed = true; + break; + } + + case MediaPlayerControlCommand::PLAYLIST_ADVANCE: { + // Internal message: a track finished, advance to next + if (ps.repeat_mode != REPEAT_ONE) { + ps.playlist_index++; + } + + // Check if we should continue playback + if (ps.playlist_index < ps.playlist.size()) { + this->queue_play_current_(pipeline, ps.playlist_delay_ms); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = 0; + this->queue_play_current_(pipeline, ps.playlist_delay_ms); + } + command_executed = true; + break; + } + + case MediaPlayerControlCommand::PLAY_CURRENT: { + // Play the item at current playlist index + if (ps.playlist_index < ps.playlist.size()) { + command_executed = this->try_execute_play_uri_(ps.playlist[ps.playlist_index], pipeline); + } else { + command_executed = true; // Index out of bounds or empty playlist + } + break; + } + + case MediaPlayerControlCommand::SEND_COMMAND: { + this->handle_player_command_(control_command.data.command, pipeline); + command_executed = true; + break; + } + } + + // Only remove from queue if successfully executed + if (command_executed) { + xQueueReceive(this->media_control_command_queue_, &control_command, 0); + + // Delete the allocated string for PLAY_URI and ENQUEUE_URI commands + if (control_command.type == MediaPlayerControlCommand::PLAY_URI || + control_command.type == MediaPlayerControlCommand::ENQUEUE_URI) { + delete control_command.data.uri; + } + } +} + +// THREAD CONTEXT: Called from main loop only (via process_control_queue_) +void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerCommand player_command, + uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *active_source = ps.active_source; + bool has_internal_playlist = (active_source != nullptr) && active_source->has_internal_playlist(); + + // Determine target source: prefer active, fall back to last + media_source::MediaSource *target_source = nullptr; + if (active_source != nullptr) { + target_source = active_source; + } else if (ps.last_source != nullptr) { + target_source = ps.last_source; + } + + switch (player_command) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: { + // Convert TOGGLE to PLAY or PAUSE based on current state + if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + } else if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) { + bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist(); + if (last_has_internal_playlist) { + ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY); + } else { + if (ps.playlist_index >= ps.playlist.size()) { + ps.playlist_index = 0; + } + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PLAY: { + if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) { + bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist(); + if (last_has_internal_playlist) { + ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY); + } else { + if (ps.playlist_index >= ps.playlist.size()) { + ps.playlist_index = 0; + } + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_STOP: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + ps.playlist.clear(); + ps.playlist_index = 0; + } + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::STOP); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_NEXT: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + if (ps.playlist_index + 1 < ps.playlist.size()) { + ps.playlist_index++; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = 0; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::NEXT); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + if (ps.playlist_index > 0) { + ps.playlist_index--; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = ps.playlist.size() - 1; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PREVIOUS); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_ONE; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ONE); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_OFF; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_OFF); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_ALL; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ALL); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + if (ps.playlist_index < ps.playlist.size()) { + ps.playlist[0] = std::move(ps.playlist[ps.playlist_index]); + ps.playlist.resize(1); + ps.playlist_index = 0; + } else { + ps.playlist.clear(); + ps.playlist_index = 0; + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST); + } + break; + } + + default: + // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL), SHUFFLE/UNSHUFFLE (PR3) are no-ops + break; + } +} + // THREAD CONTEXT: Called from main loop only. Entry points: // - HA/automation commands (direct) // - handle_play_uri_request_() via make_call().perform() (deferred from source tasks) @@ -406,10 +621,17 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call auto media_url = call.get_media_url(); if (media_url.has_value()) { - control_command.type = MediaPlayerControlCommand::PLAY_URI; + auto command = call.get_command(); + bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE; + + if (enqueue) { + control_command.type = MediaPlayerControlCommand::ENQUEUE_URI; + } else { + control_command.type = MediaPlayerControlCommand::PLAY_URI; + } // Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens - // can easily exceed 500 bytes). Deleted after the command is consumed. FreeRTOS queues require items to be - // copyable, so we store a pointer to the string in the queue rather than the string itself. + // can easily exceed 500 bytes). Deleted in process_control_queue_() after the command is consumed. FreeRTOS queues + // require items to be copyable, so we store a pointer to the string in the queue rather than the string itself. control_command.data.uri = new std::string(media_url.value()); if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { delete control_command.data.uri; @@ -454,6 +676,9 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call } media_player::MediaPlayerTraits SpeakerSourceMediaPlayer::get_traits() { + // This media player supports more traits like playlists, repeat, and shuffle, but the ESPHome API currently (March + // 2026) doesn't support those commands, so we only report pause support for now since that's used by the frontend and + // supported by our player. auto traits = media_player::MediaPlayerTraits(); traits.set_supports_pause(true); diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 7896fef295a..09670845047 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -29,7 +29,8 @@ namespace esphome::speaker_source { // - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), // handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), // set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(), -// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_() +// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(), +// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_() // // - Media source task(s): handle_media_output_() via SourceBinding::write_audio(). // Called from each source's decode task thread when streaming audio data. @@ -49,13 +50,19 @@ namespace esphome::speaker_source { // - defer(): SourceBinding::request_volume/request_mute/request_play_uri -> main loop // - Atomic fields (active_source, pending_frames): shared between all three thread contexts // -// Non-atomic pipeline fields (last_source, stopping_source, pending_source) are only accessed -// from the main loop thread. +// Non-atomic pipeline fields (last_source, stopping_source, pending_source, playlist, +// playlist_index, repeat_mode) are only accessed from the main loop thread. enum Pipeline : uint8_t { MEDIA_PIPELINE = 0, }; +enum RepeatMode : uint8_t { + REPEAT_OFF = 0, + REPEAT_ONE = 1, + REPEAT_ALL = 2, +}; + // Forward declaration class SpeakerSourceMediaPlayer; @@ -79,6 +86,9 @@ struct SourceBinding : public media_source::MediaSourceListener { }; struct PipelineContext { + /// @brief Timeout IDs for playlist delay, indexed by Pipeline enum + static constexpr const char *const TIMEOUT_IDS[] = {"next_media"}; + speaker::Speaker *speaker{nullptr}; optional format; @@ -92,6 +102,14 @@ struct PipelineContext { // Uses std::vector because the count varies across instances (multiple speaker_source media players may exist). std::vector> sources; + // Dynamic allocation is unavoidable here: URIs from Home Assistant are arbitrary-length strings + // (media URLs with tokens can easily exceed 500 bytes), and playlist size is unbounded. + // Pre-allocating fixed buffers would waste significant RAM when idle without covering worst cases. + std::vector playlist; + size_t playlist_index{0}; + RepeatMode repeat_mode{REPEAT_OFF}; + uint32_t playlist_delay_ms{0}; + // Track frames sent to speaker to correlate with playback callbacks. // Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback // callback. @@ -103,14 +121,17 @@ struct PipelineContext { struct MediaPlayerControlCommand { enum Type : uint8_t { - PLAY_URI, // Find a source that can handle this URI and play it - SEND_COMMAND, // Send command to active source + PLAY_URI, // Clear playlist, reset index, add URI, queue PLAY_CURRENT + ENQUEUE_URI, // Add URI to playlist, queue PLAY_CURRENT if idle + PLAYLIST_ADVANCE, // Advance index (or wrap for repeat_all), queue PLAY_CURRENT if more items + PLAY_CURRENT, // Play item at current playlist index (can retry if speaker not ready) + SEND_COMMAND, // Send command to active source }; Type type; uint8_t pipeline; union { - std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI) + std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI) media_player::MediaPlayerCommand command; } data; }; @@ -154,6 +175,8 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } Trigger *get_volume_trigger() { return &this->volume_trigger_; } + void set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms); + protected: // Callbacks from source bindings (pipeline index is captured at binding creation time) size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length, @@ -183,11 +206,20 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla /// @brief Determine media player state from the media pipeline's active source /// @param media_source Active source for the media pipeline (may be nullptr) + /// @param playlist_active Whether the media pipeline's playlist is in progress + /// @param old_state Previous media player state (used for transition smoothing) /// @return The appropriate MediaPlayerState - media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source) const; + media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source, + bool playlist_active, + media_player::MediaPlayerState old_state) const; + void process_control_queue_(); + void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline); bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline); media_source::MediaSource *find_source_for_uri_(const std::string &uri, uint8_t pipeline); + void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline); + void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0); + QueueHandle_t media_control_command_queue_; // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index cfcb065f57c..9e4c309c063 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -41,3 +41,8 @@ media_player: on_unmute: - media_player.play: id: media_player_id + on_volume: + - speaker_source.set_playlist_delay: + id: media_player_id + pipeline: media + delay: 500ms From 3d4ebe74ce2dc7948d2163cdf11f7f89be3c6dec Mon Sep 17 00:00:00 2001 From: Big Mike Date: Wed, 11 Mar 2026 13:00:42 -0500 Subject: [PATCH 006/279] [sensirion_common] Use SmallBufferWithHeapFallback helper (#14270) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../sensirion_common/i2c_sensirion.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 0279e08b9ff..6e244faf596 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -12,24 +12,25 @@ static const char *const TAG = "sensirion_i2c"; static const size_t BUFFER_STACK_SIZE = 16; bool SensirionI2CDevice::read_data(uint16_t *data, const uint8_t len) { - const size_t num_bytes = len * 3; - uint8_t buf[num_bytes]; + const size_t required_buffer_len = len * 3; + SmallBufferWithHeapFallback buffer(required_buffer_len); + uint8_t *temp = buffer.get(); - this->last_error_ = this->read(buf, num_bytes); + this->last_error_ = this->read(temp, required_buffer_len); if (this->last_error_ != i2c::ERROR_OK) { return false; } - for (uint8_t i = 0; i < len; i++) { - const uint8_t j = 3 * i; + for (size_t i = 0; i < len; i++) { + const size_t j = i * 3; // Use MSB first since Sensirion devices use CRC-8 with MSB first - uint8_t crc = crc8(&buf[j], 2, 0xFF, CRC_POLYNOMIAL, true); - if (crc != buf[j + 2]) { - ESP_LOGE(TAG, "CRC invalid @ %d! 0x%02X != 0x%02X", i, buf[j + 2], crc); + uint8_t crc = crc8(&temp[j], 2, 0xFF, CRC_POLYNOMIAL, true); + if (crc != temp[j + 2]) { + ESP_LOGE(TAG, "CRC invalid @ %zu! 0x%02X != 0x%02X", i, temp[j + 2], crc); this->last_error_ = i2c::ERROR_CRC; return false; } - data[i] = encode_uint16(buf[j], buf[j + 1]); + data[i] = encode_uint16(temp[j], temp[j + 1]); } return true; } From b27165a8427cb45044f7795cb46a3d6d2835cb52 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 11 Mar 2026 13:11:00 -0500 Subject: [PATCH 007/279] [speaker_source] Add shuffle support (#14653) --- .../speaker_source_media_player.cpp | 90 +++++++++++++++++-- .../speaker_source_media_player.h | 13 +++ tests/components/speaker_source/common.yaml | 4 +- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 3724e206673..cf7169df6a9 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -5,6 +5,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::speaker_source { static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; @@ -383,7 +385,8 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { // Always use our local playlist to start playback this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); ps.playlist.clear(); - ps.playlist_index = 0; // Reset index + ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist + ps.playlist_index = 0; // Reset index ps.playlist.push_back(*control_command.data.uri); // Queue PLAY_CURRENT to initiate playback @@ -396,6 +399,11 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { // Always add to our local playlist ps.playlist.push_back(*control_command.data.uri); + // If shuffle is active, add the new item to the end of the shuffle order + if (!ps.shuffle_indices.empty()) { + ps.shuffle_indices.push_back(ps.playlist.size() - 1); + } + // If nothing is playing and no upcoming items are queued, start the new item. bool nothing_playing = (active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE); @@ -425,9 +433,10 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { } case MediaPlayerControlCommand::PLAY_CURRENT: { - // Play the item at current playlist index + // Play the item at current playlist index (mapped through shuffle if active) if (ps.playlist_index < ps.playlist.size()) { - command_executed = this->try_execute_play_uri_(ps.playlist[ps.playlist_index], pipeline); + size_t actual_position = this->get_playlist_position_(pipeline); + command_executed = this->try_execute_play_uri_(ps.playlist[actual_position], pipeline); } else { command_executed = true; // Index out of bounds or empty playlist } @@ -521,6 +530,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC if (!has_internal_playlist) { this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); ps.playlist.clear(); + ps.shuffle_indices.clear(); ps.playlist_index = 0; } if (target_source != nullptr) { @@ -589,21 +599,39 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC if (!has_internal_playlist) { this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); if (ps.playlist_index < ps.playlist.size()) { - ps.playlist[0] = std::move(ps.playlist[ps.playlist_index]); + size_t actual_position = this->get_playlist_position_(pipeline); + ps.playlist[0] = std::move(ps.playlist[actual_position]); ps.playlist.resize(1); ps.playlist_index = 0; } else { ps.playlist.clear(); ps.playlist_index = 0; } + ps.shuffle_indices.clear(); } else if (target_source != nullptr) { target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST); } break; } + case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE: + if (!has_internal_playlist) { + this->shuffle_playlist_(pipeline); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::SHUFFLE); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE: + if (!has_internal_playlist) { + this->unshuffle_playlist_(pipeline); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::UNSHUFFLE); + } + break; + default: - // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL), SHUFFLE/UNSHUFFLE (PR3) are no-ops + // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL) are no-ops break; } } @@ -766,6 +794,58 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } +size_t SpeakerSourceMediaPlayer::get_playlist_position_(uint8_t pipeline) const { + const PipelineContext &ps = this->pipelines_[pipeline]; + + if (ps.shuffle_indices.empty() || ps.playlist_index >= ps.shuffle_indices.size()) { + return ps.playlist_index; + } + return ps.shuffle_indices[ps.playlist_index]; +} + +void SpeakerSourceMediaPlayer::shuffle_playlist_(uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (ps.playlist.size() <= 1) { + ps.shuffle_indices.clear(); + return; + } + + // Capture current actual position BEFORE modifying shuffle_indices + size_t current_actual = this->get_playlist_position_(pipeline); + + // Build indices vector + ps.shuffle_indices.resize(ps.playlist.size()); + for (size_t i = 0; i < ps.playlist.size(); i++) { + ps.shuffle_indices[i] = i; + } + + // Fisher-Yates shuffle using ESPHome's random helper + for (size_t i = ps.shuffle_indices.size() - 1; i > 0; i--) { + size_t j = random_uint32() % (i + 1); + std::swap(ps.shuffle_indices[i], ps.shuffle_indices[j]); + } + + // Move current track to current position (so playback continues seamlessly) + if (ps.playlist_index < ps.shuffle_indices.size()) { + for (size_t i = 0; i < ps.shuffle_indices.size(); i++) { + if (ps.shuffle_indices[i] == current_actual) { + std::swap(ps.shuffle_indices[i], ps.shuffle_indices[ps.playlist_index]); + break; + } + } + } +} + +void SpeakerSourceMediaPlayer::unshuffle_playlist_(uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (!ps.shuffle_indices.empty() && ps.playlist_index < ps.shuffle_indices.size()) { + ps.playlist_index = ps.shuffle_indices[ps.playlist_index]; + } + ps.shuffle_indices.clear(); +} + } // namespace esphome::speaker_source #endif // USE_ESP32 diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 09670845047..4fbb534110f 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -110,6 +110,10 @@ struct PipelineContext { RepeatMode repeat_mode{REPEAT_OFF}; uint32_t playlist_delay_ms{0}; + // When non-empty, playlist_index indexes into these vectors + // which contain the actual playlist indices in shuffled order + std::vector shuffle_indices; + // Track frames sent to speaker to correlate with playback callbacks. // Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback // callback. @@ -220,6 +224,15 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline); void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0); + /// @brief Maps playlist_index through shuffle indices if shuffle is active + size_t get_playlist_position_(uint8_t pipeline) const; + + /// @brief Generates shuffled indices for the playlist, keeping current track at current position + void shuffle_playlist_(uint8_t pipeline); + + /// @brief Clears shuffle indices and adjusts playlist_index to maintain current track + void unshuffle_playlist_(uint8_t pipeline); + QueueHandle_t media_control_command_queue_; // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 9e4c309c063..05b5181dfd4 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -36,10 +36,10 @@ media_player: sources: - audio_file_source on_mute: - - media_player.pause: + - media_player.shuffle: id: media_player_id on_unmute: - - media_player.play: + - media_player.unshuffle: id: media_player_id on_volume: - speaker_source.set_playlist_delay: From 03c0ce704b0c89a87f1fe2b67dbbcb6a74eb9688 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:22:48 -0400 Subject: [PATCH 008/279] Bump pyupgrade to v3.21.2 for Python 3.14 compatibility (#14699) Co-authored-by: Claude Opus 4.6 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b036da6ef16..2d8f6983959 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py311-plus] From 04bcd9f56ba24d40199f8f4cadf7761429bc942c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:25:36 -0400 Subject: [PATCH 009/279] [dashboard] Use sys.executable for dashboard subprocess commands (#14698) Co-authored-by: Jonathan Swoboda Co-authored-by: Claude Opus 4.6 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/__main__.py | 13 +++++++------ esphome/dashboard/const.py | 5 ++++- esphome/dashboard/web_server.py | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 58b995e8df4..4b0fc2cec7f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -74,6 +74,8 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] + # Maximum buffer size for serial log reading to prevent unbounded memory growth SERIAL_BUFFER_MAX_SIZE = 65536 @@ -1307,9 +1309,8 @@ def command_update_all(args: ArgsProtocol) -> int | None: files = list_yaml_files(args.configuration) def build_command(f): - if CORE.dashboard: - return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"] - return ["esphome", "run", f, "--no-logs", "--device", "OTA"] + dashboard = ["--dashboard"] if CORE.dashboard else [] + return [*ESPHOME_COMMAND, *dashboard, "run", f, "--no-logs", "--device", "OTA"] return run_multiple_configs(files, build_command) @@ -1458,7 +1459,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path.write_text(new_raw, encoding="utf-8") - rc = run_external_process("esphome", "config", str(new_path)) + rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() @@ -1476,7 +1477,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: cli_args.insert(0, "--dashboard") try: - rc = run_external_process("esphome", *cli_args) + rc = run_external_process(*ESPHOME_COMMAND, *cli_args) except KeyboardInterrupt: rc = 1 if rc != 0: @@ -1873,7 +1874,7 @@ def run_esphome(argv): # argv[0] is the program path, skip it since we prefix with "esphome" def build_command(f): return ( - ["esphome"] + [*ESPHOME_COMMAND] + [arg for arg in argv[1:] if arg not in args.configuration] + [str(f)] ) diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py index ada5575d0e1..9cadc442ef2 100644 --- a/esphome/dashboard/const.py +++ b/esphome/dashboard/const.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + from esphome.enum import StrEnum @@ -26,4 +28,5 @@ MAX_EXECUTOR_WORKERS = 48 SENTINEL = object() -DASHBOARD_COMMAND = ["esphome", "--dashboard"] +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] +DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 92cab929ef4..b8e17244e53 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -52,7 +52,7 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from ..helpers import write_file -from .const import DASHBOARD_COMMAND, DashboardEvent +from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent from .core import DASHBOARD, ESPHomeDashboard, Event from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .models import build_device_list_response @@ -1079,7 +1079,7 @@ class DownloadBinaryRequestHandler(BaseHandler): return if not path.is_file(): - args = ["esphome", "idedata", settings.rel_path(configuration)] + args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] rc, stdout, _ = await async_run_system_command(args) if rc != 0: @@ -1462,7 +1462,7 @@ class JsonConfigRequestHandler(BaseHandler): self.send_error(404) return - args = ["esphome", "config", str(filename), "--show-secrets"] + args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] rc, stdout, stderr = await async_run_system_command(args) From bef5e4de9c0a89f319c9f7124814cf7390fb922e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 11 Mar 2026 13:29:17 -0500 Subject: [PATCH 010/279] [speaker_source] Add announcement pipeline (#14654) --- .../components/speaker_source/media_player.py | 125 +++++++++++++----- .../speaker_source_media_player.cpp | 74 +++++++++-- .../speaker_source_media_player.h | 27 ++-- tests/components/speaker_source/common.yaml | 21 ++- 4 files changed, 186 insertions(+), 61 deletions(-) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 9080bebcae0..fe50536e8fd 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -20,6 +20,7 @@ DEPENDENCIES = ["media_source", "speaker"] CODEOWNERS = ["@kahrendt"] +CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" CONF_MEDIA_PIPELINE = "media_pipeline" CONF_ON_MUTE = "on_mute" CONF_PIPELINE = "pipeline" @@ -42,6 +43,19 @@ PipelineContext = speaker_source_ns.struct("PipelineContext") Pipeline = speaker_source_ns.enum("Pipeline") PIPELINE_ENUM = { "media": Pipeline.MEDIA_PIPELINE, + "announcement": Pipeline.ANNOUNCEMENT_PIPELINE, +} + +# Maps config key -> (C++ Pipeline enum value, format purpose) +_PIPELINE_INFO = { + CONF_MEDIA_PIPELINE: ( + Pipeline.MEDIA_PIPELINE, + media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], + ), + CONF_ANNOUNCEMENT_PIPELINE: ( + Pipeline.ANNOUNCEMENT_PIPELINE, + media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], + ), } SetPlaylistDelayAction = speaker_source_ns.class_( @@ -59,7 +73,7 @@ FORMAT_MAPPING = { # Returns a media_player.MediaPlayerSupportedFormat struct with the configured # format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline: ConfigType): +def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj): args = [ media_player.MediaPlayerSupportedFormat, ] @@ -68,7 +82,7 @@ def _get_supported_format_struct(pipeline: ConfigType): args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - args.append(("purpose", media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"])) + args.append(("purpose", purpose)) # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails # if the number of bytes per sample is specified for MP3. @@ -115,6 +129,40 @@ PIPELINE_SCHEMA = cv.Schema( ) +def _validate_no_shared_resources(config: ConfigType) -> ConfigType: + announcement_config = config.get(CONF_ANNOUNCEMENT_PIPELINE) + media_config = config.get(CONF_MEDIA_PIPELINE) + + # Check for duplicates within each pipeline + for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): + if pipeline_config := config.get(pipeline_key): + source_ids = [s.id for s in pipeline_config[CONF_SOURCES]] + if len(source_ids) != len(set(source_ids)): + raise cv.Invalid( + f"Duplicate media sources in {pipeline_key}. " + "Each media source can only appear once per pipeline." + ) + + # Check for sources shared between pipelines + if announcement_config and media_config: + if announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER]: + raise cv.Invalid( + "The announcement and media pipelines cannot use the same speaker. " + "Use the `mixer` speaker component to create two source speakers." + ) + + announcement_source_ids = {s.id for s in announcement_config[CONF_SOURCES]} + media_source_ids = {s.id for s in media_config[CONF_SOURCES]} + shared = announcement_source_ids & media_source_ids + if shared: + raise cv.Invalid( + f"Media sources cannot be shared between pipelines: {', '.join(shared)}. " + "Create separate media source instances for each pipeline." + ) + + return config + + def _validate_volume_settings(config: ConfigType) -> ConfigType: # CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]: @@ -131,7 +179,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage, - cv.Required(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True), cv.Optional(CONF_ON_UNMUTE): automation.validate_automation(single=True), cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True), @@ -140,23 +189,37 @@ CONFIG_SCHEMA = cv.All( .extend(cv.COMPONENT_SCHEMA) .extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)), cv.only_on_esp32, + cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE), + _validate_no_shared_resources, _validate_volume_settings, ) def _final_validate_codecs(config: ConfigType) -> ConfigType: - pipeline = config[CONF_MEDIA_PIPELINE] - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": + # "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available. + # When a specific format is set, only that codec is requested. + needed_formats: set[str] = set() + need_all = False + + for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): + if pipeline := config.get(pipeline_key): + fmt = pipeline[CONF_FORMAT] + if fmt == "NONE": + need_all = True + else: + needed_formats.add(fmt) + + if need_all: audio.request_flac_support() audio.request_mp3_support() audio.request_opus_support() - elif fmt == "FLAC": - audio.request_flac_support() - elif fmt == "MP3": - audio.request_mp3_support() - elif fmt == "OPUS": - audio.request_opus_support() + else: + if "FLAC" in needed_formats: + audio.request_flac_support() + if "MP3" in needed_formats: + audio.request_mp3_support() + if "OPUS" in needed_formats: + audio.request_opus_support() return config @@ -164,7 +227,8 @@ def _final_validate_codecs(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_MEDIA_PIPELINE): _validate_pipeline, + cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): _validate_pipeline, + cv.Optional(CONF_MEDIA_PIPELINE): _validate_pipeline, }, extra=cv.ALLOW_EXTRA, ), @@ -182,26 +246,25 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_volume_max(config[CONF_VOLUME_MAX])) cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) - pipeline_config = config[CONF_MEDIA_PIPELINE] - pipeline_enum = Pipeline.MEDIA_PIPELINE + for pipeline_key, (pipeline_enum, purpose) in _PIPELINE_INFO.items(): + if pipeline_config := config.get(pipeline_key): + for source in pipeline_config[CONF_SOURCES]: + src = await cg.get_variable(source) + cg.add(var.add_media_source(pipeline_enum, src)) - for source in pipeline_config[CONF_SOURCES]: - src = await cg.get_variable(source) - cg.add(var.add_media_source(pipeline_enum, src)) - - cg.add( - var.set_speaker( - pipeline_enum, - await cg.get_variable(pipeline_config[CONF_SPEAKER]), - ) - ) - if pipeline_config[CONF_FORMAT] != "NONE": - cg.add( - var.set_format( - pipeline_enum, - _get_supported_format_struct(pipeline_config), + cg.add( + var.set_speaker( + pipeline_enum, + await cg.get_variable(pipeline_config[CONF_SPEAKER]), + ) ) - ) + if pipeline_config[CONF_FORMAT] != "NONE": + cg.add( + var.set_format( + pipeline_enum, + _get_supported_format_struct(pipeline_config, purpose), + ) + ) if on_mute := config.get(CONF_ON_MUTE): await automation.build_automation( diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index cf7169df6a9..9d02ac9c744 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -128,6 +128,7 @@ void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const // Smart source is requesting the player to play a different URI auto call = this->make_call(); call.set_media_url(uri); + call.set_announcement(pipeline == ANNOUNCEMENT_PIPELINE); call.perform(); } @@ -209,7 +210,7 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so } // THREAD CONTEXT: Called from main loop (loop) -media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_( +media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_source_state_( media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const { if (source != nullptr) { switch (source->get_state()) { @@ -218,7 +219,7 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat case media_source::MediaSourceState::PAUSED: return media_player::MEDIA_PLAYER_STATE_PAUSED; case media_source::MediaSourceState::ERROR: - ESP_LOGE(TAG, "Source error"); + ESP_LOGE(TAG, "Media source error"); return media_player::MEDIA_PLAYER_STATE_IDLE; case media_source::MediaSourceState::IDLE: default: @@ -237,16 +238,47 @@ void SpeakerSourceMediaPlayer::loop() { // Process queued control commands this->process_control_queue_(); - // Update state based on active sources + // Update state based on active sources - announcement pipeline takes priority media_player::MediaPlayerState old_state = this->state; + PipelineContext &ann_ps = this->pipelines_[ANNOUNCEMENT_PIPELINE]; PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE]; // Check playlist state to detect transitions between items + bool announcement_playlist_active = (ann_ps.playlist_index < ann_ps.playlist.size()) || + (ann_ps.repeat_mode != REPEAT_OFF && !ann_ps.playlist.empty()); bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) || (media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty()); - this->state = this->get_media_pipeline_state_(media_ps.active_source, media_playlist_active, old_state); + // Check announcement pipeline first + media_source::MediaSource *announcement_source = ann_ps.active_source; + if (announcement_source != nullptr) { + media_source::MediaSourceState announcement_state = announcement_source->get_state(); + if (announcement_state != media_source::MediaSourceState::IDLE) { + // Announcement is active - announcements take priority and never report PAUSED + switch (announcement_state) { + case media_source::MediaSourceState::PLAYING: + case media_source::MediaSourceState::PAUSED: // Treat paused announcements as announcing + this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING; + break; + case media_source::MediaSourceState::ERROR: + ESP_LOGE(TAG, "Announcement source error"); + // Fall through to media pipeline state + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + break; + default: + break; + } + } else { + // Announcement source is idle, fall through to media pipeline + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + } + } else if (announcement_playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) { + this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING; + } else { + // No active announcement, check media pipeline + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + } if (this->state != old_state) { this->publish_state(); @@ -357,7 +389,7 @@ void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t // The timeout callback also runs on the main loop. void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) { if (delay_ms > 0) { - this->set_timeout(PipelineContext::TIMEOUT_IDS[pipeline], delay_ms, + this->set_timeout(PIPELINE_TIMEOUT_IDS[pipeline], delay_ms, [this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); }); } else { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); @@ -366,7 +398,7 @@ void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t de // THREAD CONTEXT: Called from main loop (loop) void SpeakerSourceMediaPlayer::process_control_queue_() { - MediaPlayerControlCommand control_command; + MediaPlayerControlCommand control_command{}; // Use peek to check command without removing it if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { @@ -383,7 +415,7 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { switch (control_command.type) { case MediaPlayerControlCommand::PLAY_URI: { // Always use our local playlist to start playback - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); ps.playlist.clear(); ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist ps.playlist_index = 0; // Reset index @@ -528,7 +560,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_STOP: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); ps.playlist.clear(); ps.shuffle_indices.clear(); ps.playlist_index = 0; @@ -541,7 +573,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_NEXT: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); if (ps.playlist_index + 1 < ps.playlist.size()) { ps.playlist_index++; this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); @@ -557,7 +589,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); if (ps.playlist_index > 0) { ps.playlist_index--; this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); @@ -597,7 +629,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); if (ps.playlist_index < ps.playlist.size()) { size_t actual_position = this->get_playlist_position_(pipeline); ps.playlist[0] = std::move(ps.playlist[actual_position]); @@ -644,8 +676,24 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call return; } - MediaPlayerControlCommand control_command; - control_command.pipeline = MEDIA_PIPELINE; + MediaPlayerControlCommand control_command{}; + + // Determine which pipeline to use based on announcement flag, falling back if the preferred pipeline + // is not configured + auto announcement = call.get_announcement(); + if (announcement.has_value() && announcement.value()) { + if (this->pipelines_[ANNOUNCEMENT_PIPELINE].is_configured()) { + control_command.pipeline = ANNOUNCEMENT_PIPELINE; + } else { + control_command.pipeline = MEDIA_PIPELINE; + } + } else { + if (this->pipelines_[MEDIA_PIPELINE].is_configured()) { + control_command.pipeline = MEDIA_PIPELINE; + } else { + control_command.pipeline = ANNOUNCEMENT_PIPELINE; + } + } auto media_url = call.get_media_url(); if (media_url.has_value()) { diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 4fbb534110f..652390edd22 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -28,7 +28,7 @@ namespace esphome::speaker_source { // // - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), // handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), -// set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(), +// set_volume_(), set_mute_state_(), control(), get_source_state_(), // find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(), // process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_() // @@ -55,6 +55,7 @@ namespace esphome::speaker_source { enum Pipeline : uint8_t { MEDIA_PIPELINE = 0, + ANNOUNCEMENT_PIPELINE = 1, }; enum RepeatMode : uint8_t { @@ -85,10 +86,10 @@ struct SourceBinding : public media_source::MediaSourceListener { void request_play_uri(const std::string &uri) override; }; -struct PipelineContext { - /// @brief Timeout IDs for playlist delay, indexed by Pipeline enum - static constexpr const char *const TIMEOUT_IDS[] = {"next_media"}; +/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum +static constexpr uint32_t PIPELINE_TIMEOUT_IDS[] = {1, 2}; +struct PipelineContext { speaker::Speaker *speaker{nullptr}; optional format; @@ -132,7 +133,7 @@ struct MediaPlayerControlCommand { SEND_COMMAND, // Send command to active source }; Type type; - uint8_t pipeline; + uint8_t pipeline; // MEDIA_PIPELINE or ANNOUNCEMENT_PIPELINE union { std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI) @@ -208,14 +209,13 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla /// @brief Saves the current volume and mute state to the flash for restoration. void save_volume_restore_state_(); - /// @brief Determine media player state from the media pipeline's active source - /// @param media_source Active source for the media pipeline (may be nullptr) - /// @param playlist_active Whether the media pipeline's playlist is in progress + /// @brief Determine media player state from a pipeline's active source + /// @param media_source Active source (may be nullptr) + /// @param playlist_active Whether the pipeline's playlist is in progress /// @param old_state Previous media player state (used for transition smoothing) /// @return The appropriate MediaPlayerState - media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source, - bool playlist_active, - media_player::MediaPlayerState old_state) const; + media_player::MediaPlayerState get_source_state_(media_source::MediaSource *media_source, bool playlist_active, + media_player::MediaPlayerState old_state) const; void process_control_queue_(); void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline); @@ -235,8 +235,9 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla QueueHandle_t media_control_command_queue_; - // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. - std::array pipelines_; + // Pipeline context for media (index 0) and announcement (index 1) pipelines. + // See THREADING MODEL at top of namespace for access rules. + std::array pipelines_; // Used to save volume/mute state for restoration on reboot ESPPreferenceObject pref_; diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 05b5181dfd4..7d663b802c6 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -10,6 +10,11 @@ speaker: i2s_dout_pin: ${i2s_dout_pin} sample_rate: 48000 num_channels: 2 + - platform: mixer + output_speaker: speaker_id + source_speakers: + - id: announcement_mixer_speaker_id + - id: media_mixer_speaker_id audio_file: - id: test_audio @@ -19,7 +24,9 @@ audio_file: media_source: - platform: audio_file - id: audio_file_source + id: announcement_audio_file_source + - platform: audio_file + id: media_audio_file_source media_player: - platform: speaker_source @@ -29,12 +36,18 @@ media_player: volume_initial: 0.75 volume_max: 0.95 volume_min: 0.0 - media_pipeline: - speaker: speaker_id + announcement_pipeline: + speaker: announcement_mixer_speaker_id format: FLAC num_channels: 1 sources: - - audio_file_source + - announcement_audio_file_source + media_pipeline: + speaker: media_mixer_speaker_id + format: FLAC + num_channels: 1 + sources: + - media_audio_file_source on_mute: - media_player.shuffle: id: media_player_id From e7c3277eeb095a5b3e2c88ca4c7631d43528099c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:34:53 +1300 Subject: [PATCH 011/279] Bump version to 2026.4.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1a9e0b4e10f..cfdb74bd196 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.3.0-dev +PROJECT_NUMBER = 2026.4.0-dev # 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 d409514f3c7..33a2526d38b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0-dev" +__version__ = "2026.4.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 02f7aee680bb0a7b7a7a54e1b2cd759f8876d77c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:34:53 +1300 Subject: [PATCH 012/279] Bump version to 2026.3.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1a9e0b4e10f..9f5cd0a2ce3 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.3.0-dev +PROJECT_NUMBER = 2026.3.0b1 # 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 d409514f3c7..eb49c9a1d77 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0-dev" +__version__ = "2026.3.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 928f6f18660af1ffde5a1fc91f02c8a31fd43021 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 08:57:43 -1000 Subject: [PATCH 013/279] [ci] Add PR title check for unescaped angle brackets (#14701) --- .github/workflows/pr-title-check.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 198b9a6b25c..2ad023ed1b0 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -65,6 +65,18 @@ jobs: return; } + // Check for angle brackets not wrapped in backticks. + // Astro docs MDX treats bare < as JSX component opening tags. + const stripped = title.replace(/`[^`]*`/g, ''); + if (/[<>]/.test(stripped)) { + core.setFailed( + 'PR title contains `<` or `>` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components.\n' + + 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + ); + return; + } + // Check title starts with [tag] prefix const bracketPattern = /^\[\w+\]/; if (!bracketPattern.test(title)) { From b6ff7185e74da275db2bef375732b26d2e125f20 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:04:07 +1300 Subject: [PATCH 014/279] [ci] Dont run codeowners workflows on release or beta PRs (#14703) --- .github/workflows/codeowner-approved-label-update.yml | 3 +++ .github/workflows/codeowner-review-request.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 0bce33ebe29..34ff934b773 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -10,6 +10,9 @@ name: Codeowner Approved Label on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] + branches-ignore: + - release + - beta permissions: issues: write diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 02bf0e4a29e..a89c03ba042 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -13,6 +13,9 @@ on: # Needs to be pull_request_target to get write permissions pull_request_target: types: [opened, reopened, synchronize, ready_for_review] + branches-ignore: + - release + - beta permissions: pull-requests: write From 73f305ff9c9c94c3ca7e7e6a3f2b8e10749a0147 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:19 -1000 Subject: [PATCH 015/279] Bump tornado from 6.5.4 to 6.5.5 (#14704) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3da2d52b44b..e634bcb1046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.4 +tornado==6.5.5 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From a060f175ad04bf0f497a178501ca4df414b002a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:46 -1000 Subject: [PATCH 016/279] Bump actions/download-artifact from 8.0.0 to 8.0.1 (#14705) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c9e8c58bcc..461e676c4e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,13 +945,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f68e9c873f..0ed41d99c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download digests - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: digests-* path: /tmp/digests From 409640c0ee441d683b2939c2235f2bee184aecaf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:30:44 -0400 Subject: [PATCH 017/279] [esp32_hosted] Bump esp_hosted to 2.12.1 (#14708) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6d49053d6d5..a51ae2cd666 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -105,7 +105,7 @@ async def to_code(config): if framework_ver >= cv.Version(5, 5, 0): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index acd7f7a4798..f7fd3e67bc3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.0 + version: 2.12.1 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From ddc40f44fa81f41ad8970e1e607d50d646e8f2cc Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 19:56:25 -0500 Subject: [PATCH 018/279] [ethernet] ESP32-P4 Ethernet compilation fix (#14714) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/ethernet/ethernet_component.cpp | 18 +----------------- .../components/ethernet/ethernet_component.h | 2 ++ esphome/components/ethernet/ethernet_helpers.c | 8 ++++++++ .../components/ethernet/test.esp32-p4-idf.yaml | 1 + 4 files changed, 12 insertions(+), 17 deletions(-) create mode 100644 esphome/components/ethernet/ethernet_helpers.c create mode 100644 tests/components/ethernet/test.esp32-p4-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index d6b0d40cd95..e0788e11498 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -21,22 +21,6 @@ namespace esphome::ethernet { -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) -// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 -#ifdef USE_ESP32_VARIANT_ESP32P4 -#undef ETH_ESP32_EMAC_DEFAULT_CONFIG -#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ - { \ - .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ - .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ - .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ - .emac_dataif_gpio = \ - {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ - .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ - } -#endif -#endif - static const char *const TAG = "ethernet"; // PHY register size for hex logging @@ -162,7 +146,7 @@ void EthernetComponent::setup() { phy_config.phy_addr = this->phy_addr_; phy_config.reset_gpio_num = this->power_pin_; - eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index d9f05be9de0..c464e20b843 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -15,6 +15,8 @@ #include "esp_mac.h" #include "esp_idf_version.h" +extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); + namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c new file mode 100644 index 00000000000..96faccad24c --- /dev/null +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -0,0 +1,8 @@ +#include "esp_eth_mac_esp.h" + +// ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers +// which are valid in C but not in C++. This wrapper allows C++ code to get +// the default config without replicating the macro's contents. +eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { + return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); +} diff --git a/tests/components/ethernet/test.esp32-p4-idf.yaml b/tests/components/ethernet/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e52329d7ea2 --- /dev/null +++ b/tests/components/ethernet/test.esp32-p4-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ip101.yaml From 8daa946afa37e92500809cd0ad60e1fd429cb3ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 15:00:20 -1000 Subject: [PATCH 019/279] [esp32] Add crash handler to capture and report backtrace across reboots (#14709) --- esphome/components/api/api_connection.h | 6 + esphome/components/api/client.py | 21 ++ esphome/components/esp32/__init__.py | 5 + esphome/components/esp32/core.cpp | 6 + esphome/components/esp32/crash_handler.cpp | 355 +++++++++++++++++++++ esphome/components/esp32/crash_handler.h | 18 ++ esphome/components/logger/logger_esp32.cpp | 4 + esphome/core/defines.h | 1 + esphome/platformio_api.py | 7 + tests/unit_tests/test_platformio_api.py | 28 ++ 10 files changed, 451 insertions(+) create mode 100644 esphome/components/esp32/crash_handler.cpp create mode 100644 esphome/components/esp32/crash_handler.h diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3356511684f..60cc3e91b11 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -14,6 +14,9 @@ #include "api_server.h" #include "esphome/core/application.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_CRASH_HANDLER +#include "esphome/components/esp32/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -235,6 +238,9 @@ class APIConnection final : public APIServerConnectionBase { this->flags_.log_subscription = msg.level; if (msg.dump_config) App.schedule_dump_config(); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; } diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd5..0e71ad8fcbf 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime +import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard + backtrace_state = False + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = getattr(module, "process_stacktrace") + except (AttributeError, ImportError): + pass def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" + nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: ) for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + for raw_line in text.splitlines(): + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace( + config, raw_line, backtrace_state + ) + else: + backtrace_state = process_stacktrace( + config, raw_line, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 52e70501dcf..475de6aa3e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1442,6 +1442,11 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") + # Arduino already wraps esp_panic_handler for its own backtrace handler, + # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 46c000562e1..cba25bca2b2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -36,6 +37,11 @@ void arch_restart() { } void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp new file mode 100644 index 00000000000..ecf30d78781 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,355 @@ +#ifdef USE_ESP32 + +#include "esphome/core/defines.h" +#ifdef USE_ESP32_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#include +#include +#elif CONFIG_IDF_TARGET_ARCH_RISCV +#include +#endif + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +static constexpr size_t MAX_BACKTRACE = 16; + +// Check if an address looks like code (flash-mapped or IRAM). +// Must be safe to call from panic context (no flash access needed). +static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { + return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH); +} + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Check if a code address is a real return address by verifying the preceding +// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during +// panic) so flash cache is available and both IRAM and IROM are safely readable. +static inline bool is_return_addr(uint32_t addr) { + if (!is_code_addr(addr) || addr < 4) + return false; + // A return address on the stack points to the instruction after a call. + // Check for 4-byte JAL/JALR call instruction before this address. + // Use memcpy for alignment safety — RISC-V C extension means code addresses + // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. + uint32_t inst; + memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); + // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd + uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode + uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7) + // Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7) + if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80) + return true; + // Check for 2-byte compressed c.jalr before this address (C extension). + // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 + if (addr >= 2) { + uint16_t c_inst = *(uint16_t *) (addr - 2); + if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) + return true; + } + return false; +} +#endif + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset but contains garbage after power cycle. +// Validated by magic marker. Static linkage since it's only used within this file. +// Version field is first so future firmware can always identify the struct layout. +// Magic is second to validate the data. Remaining fields can change between versions. +// Version is uint32_t because it would be padded to 4 bytes anyway before the next +// uint32_t field, so we use the full width rather than wasting 3 bytes of padding. +static constexpr uint32_t CRASH_DATA_VERSION = 1; +struct RawCrashData { + uint32_t version; + uint32_t magic; + uint32_t pc; + uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) + uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) + uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) +}; +static RawCrashData __attribute__((section(".noinit"))) +s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +void crash_handler_read_and_clear() { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { + s_crash_data_valid = true; + // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data + if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count) + s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count; + if (s_raw_crash_data.exception > 4) // panic_exception_t max value + s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT + if (s_raw_crash_data.pseudo_excause > 1) + s_raw_crash_data.pseudo_excause = 0; + } + // Clear magic regardless so we don't re-report on next normal reboot + s_raw_crash_data.magic = 0; +} + +bool crash_handler_has_data() { return s_crash_data_valid; } + +// Look up the exception cause as a human-readable string. +// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays +// not exposed via any public API. +static const char *get_exception_reason() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + if (s_raw_crash_data.pseudo_excause) { + // SoC-level panic: watchdog, cache error, etc. + // Keep in sync with ESP-IDF's PANIC_RSN_* defines + static const char *const PSEUDO_REASON[] = { + "Unknown reason", // 0 + "Unhandled debug exception", // 1 + "Double exception", // 2 + "Unhandled kernel exception", // 3 + "Coprocessor exception", // 4 + "Interrupt wdt timeout on CPU0", // 5 + "Interrupt wdt timeout on CPU1", // 6 + "Cache error", // 7 + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0])) + return PSEUDO_REASON[cause]; + return PSEUDO_REASON[0]; + } + // Real Xtensa exception + static const char *const REASON[] = { + "IllegalInstruction", + "Syscall", + "InstructionFetchError", + "LoadStoreError", + "Level1Interrupt", + "Alloca", + "IntegerDivideByZero", + "PCValue", + "Privileged", + "LoadStoreAlignment", + nullptr, + nullptr, + "InstrPDAddrError", + "LoadStorePIFDataError", + "InstrPIFAddrError", + "LoadStorePIFAddrError", + "InstTLBMiss", + "InstTLBMultiHit", + "InstFetchPrivilege", + nullptr, + "InstrFetchProhibited", + nullptr, + nullptr, + nullptr, + "LoadStoreTLBMiss", + "LoadStoreTLBMultihit", + "LoadStorePrivilege", + nullptr, + "LoadProhibited", + "StoreProhibited", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // For SoC-level panics (watchdog, cache error), mcause holds IDF-internal + // interrupt numbers, not standard RISC-V cause codes. The exception type + // field already identifies these, so just return null to use the type name. + if (s_raw_crash_data.pseudo_excause) + return nullptr; + static const char *const REASON[] = { + "Instruction address misaligned", + "Instruction access fault", + "Illegal instruction", + "Breakpoint", + "Load address misaligned", + "Load access fault", + "Store address misaligned", + "Store access fault", + "Environment call from U-mode", + "Environment call from S-mode", + nullptr, + "Environment call from M-mode", + "Instruction page fault", + "Load page fault", + nullptr, + "Store page fault", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#endif + return "Unknown"; +} + +// Exception type names matching panic_exception_t enum +static const char *get_exception_type() { + static const char *const TYPES[] = { + "Debug exception", // PANIC_EXCEPTION_DEBUG + "Interrupt wdt", // PANIC_EXCEPTION_IWDT + "Task wdt", // PANIC_EXCEPTION_TWDT + "Abort", // PANIC_EXCEPTION_ABORT + "Fault", // PANIC_EXCEPTION_FAULT + }; + uint8_t exc = s_raw_crash_data.exception; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; + return "Unknown"; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!s_crash_data_valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + const char *reason = get_exception_reason(); + if (reason != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + } else { + ESP_LOGE(TAG, " Reason: %s", get_exception_type()); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + uint8_t bt_num = 0; + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif +#if CONFIG_IDF_TARGET_ARCH_RISCV + const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[256]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::esp32 + +// --- Panic handler wrapper --- +// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data +// into NOINIT memory before the normal panic handler runs. +// +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism +extern void __real_esp_panic_handler(panic_info_t *info); + +void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { + // Save the faulting PC and exception info + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; + s_raw_crash_data.exception = (uint8_t) info->exception; + s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + +#if CONFIG_IDF_TARGET_ARCH_XTENSA + // Xtensa: walk the backtrace using the public API + if (info->frame != nullptr) { + auto *xt_frame = (XtExcFrame *) info->frame; + s_raw_crash_data.cause = xt_frame->exccause; + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) xt_frame->pc, + .sp = (uint32_t) xt_frame->a1, + .next_pc = (uint32_t) xt_frame->a0, + .exc_frame = xt_frame, + }; + + uint8_t count = 0; + // First frame PC + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + s_raw_crash_data.backtrace[count++] = first_pc; + } + // Walk remaining frames + while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) { + break; + } + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + s_raw_crash_data.backtrace[count++] = pc; + } + } + s_raw_crash_data.backtrace_count = count; + } + +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // RISC-V: capture MEPC + RA, then scan stack for code addresses + if (info->frame != nullptr) { + auto *rv_frame = (RvExcFrame *) info->frame; + s_raw_crash_data.cause = rv_frame->mcause; + uint8_t count = 0; + + // Save MEPC (fault PC) and RA (return address) + if (is_code_addr(rv_frame->mepc)) { + s_raw_crash_data.backtrace[count++] = rv_frame->mepc; + } + if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { + s_raw_crash_data.backtrace[count++] = rv_frame->ra; + } + + // Track how many entries came from registers (MEPC/RA) so we can + // skip return-address validation for them at log time. + s_raw_crash_data.reg_frame_count = count; + + // Scan stack for code addresses — captures broadly during panic, + // filtered by is_return_addr() at log time when flash is accessible. + auto *scan_start = (uint32_t *) rv_frame->sp; + for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { + s_raw_crash_data.backtrace[count++] = val; + } + } + s_raw_crash_data.backtrace_count = count; + } +#endif + + // Write version and magic last — ensures all data is written before we mark it valid + s_raw_crash_data.version = CRASH_DATA_VERSION; + s_raw_crash_data.magic = CRASH_MAGIC; + + // Call the real panic handler (prints to UART, does core dump, reboots, etc.) + __real_esp_panic_handler(info); +} + +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +} // extern "C" + +#endif // USE_ESP32_CRASH_HANDLER +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 00000000000..97a4d4e1162 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP32_CRASH_HANDLER + +namespace esphome::esp32 { + +/// Read crash data from NOINIT memory and clear the magic marker. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::esp32 + +#endif // USE_ESP32_CRASH_HANDLER diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4fa..f5bf7822899 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "logger.h" +#include "esphome/components/esp32/crash_handler.h" #include #include @@ -117,6 +118,9 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cec77fe2e27..a33f10cb9c0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -195,6 +195,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESP32_CRASH_HANDLER #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_OTA_ROLLBACK diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 5d4065207f0..cb080b2a953 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -340,6 +340,8 @@ STACKTRACE_ESP32_BACKTRACE_RE = re.compile( r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" ) STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") @@ -371,6 +373,11 @@ def process_stacktrace(config, line, backtrace_state): ) _decode_pc(config, match.group(1)) + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # ESP32 single-line backtrace match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) if match is not None: diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 16861442777..e1b3908c249 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -673,6 +673,34 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = platformio_api.process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt0, False) + mock_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt1, False) + mock_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False + + def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {}) From bb7d96b954d12e99c75a02c4ebc6f12c62382942 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Thu, 12 Mar 2026 03:31:17 +0100 Subject: [PATCH 020/279] [const] Add UNIT_METER_PER_SECOND, UNIT_MILLILITRE, UNIT_POUND to const.py (#14713) --- esphome/const.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/const.py b/esphome/const.py index 33a2526d38b..29ce0303297 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1235,6 +1235,7 @@ UNIT_LITRE = "L" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" UNIT_METER = "m" +UNIT_METER_PER_SECOND = "m/s" UNIT_METER_PER_SECOND_SQUARED = "m/s²" UNIT_MICROAMP = "µA" UNIT_MICROGRAMS_PER_CUBIC_METER = "µg/m³" @@ -1244,6 +1245,7 @@ UNIT_MICROSILVERTS_PER_HOUR = "µSv/h" UNIT_MICROTESLA = "µT" UNIT_MILLIAMP = "mA" UNIT_MILLIGRAMS_PER_CUBIC_METER = "mg/m³" +UNIT_MILLILITRE = "mL" UNIT_MILLIMETER = "mm" UNIT_MILLISECOND = "ms" UNIT_MILLISIEMENS_PER_CENTIMETER = "mS/cm" @@ -1255,6 +1257,7 @@ UNIT_PARTS_PER_MILLION = "ppm" UNIT_PASCAL = "Pa" UNIT_PERCENT = "%" UNIT_PH = "pH" +UNIT_POUND = "lb" UNIT_PULSES = "pulses" UNIT_PULSES_PER_MINUTE = "pulses/min" UNIT_REVOLUTIONS_PER_MINUTE = "RPM" From 7f38d95424d0d5a29acfaba0b891d5f847320ad7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 23:48:27 -0500 Subject: [PATCH 021/279] [ethernet] ESP32-S3 Ethernet compilation fix (#14717) --- esphome/components/ethernet/ethernet_component.h | 3 +++ esphome/components/ethernet/ethernet_helpers.c | 2 ++ tests/components/ethernet/common-w5500.yaml | 4 ++-- tests/components/ethernet/test.esp32-s3-idf.yaml | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tests/components/ethernet/test.esp32-s3-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index c464e20b843..f7a0996fb74 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -11,11 +11,14 @@ #include "esp_eth.h" #include "esp_eth_mac.h" +#include "esp_eth_mac_esp.h" #include "esp_netif.h" #include "esp_mac.h" #include "esp_idf_version.h" +#if CONFIG_ETH_USE_ESP32_EMAC extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); +#endif namespace esphome::ethernet { diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c index 96faccad24c..963db3ff1c4 100644 --- a/esphome/components/ethernet/ethernet_helpers.c +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -3,6 +3,8 @@ // ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers // which are valid in C but not in C++. This wrapper allows C++ code to get // the default config without replicating the macro's contents. +#if CONFIG_ETH_USE_ESP32_EMAC eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); } +#endif diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index 1f8b8650dd0..bf3f6f3f0c4 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -2,10 +2,10 @@ ethernet: type: W5500 clk_pin: 19 mosi_pin: 21 - miso_pin: 23 + miso_pin: 17 cs_pin: 18 interrupt_pin: 36 - reset_pin: 22 + reset_pin: 12 clock_speed: 10Mhz manual_ip: static_ip: 192.168.178.56 diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..36f1b5365f1 --- /dev/null +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -0,0 +1 @@ +<<: !include common-w5500.yaml From f8a22b87b8908c33903355a74e3d593e29584f54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 19:23:01 -1000 Subject: [PATCH 022/279] [rp2040] Fix crash handler design flaws (#14716) --- esphome/components/api/api_connection.h | 6 ++++++ esphome/components/logger/logger_rp2040.cpp | 5 +++++ esphome/components/rp2040/__init__.py | 1 + esphome/components/rp2040/core.cpp | 6 +++++- esphome/components/rp2040/crash_handler.cpp | 23 ++++++++++++++++----- esphome/components/rp2040/crash_handler.h | 8 ++++++- esphome/core/defines.h | 1 + 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 60cc3e91b11..68f698d1902 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -17,6 +17,9 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -240,6 +243,9 @@ class APIConnection final : public APIServerConnectionBase { App.schedule_dump_config(); #ifdef USE_ESP32_CRASH_HANDLER esp32::crash_handler_log(); +#endif +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_log(); #endif } #ifdef USE_API_HOMEASSISTANT_SERVICES diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f76b823a8f7..b7225c2a258 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,6 +1,9 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,7 +29,9 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index b15811241ca..276187b273c 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -212,6 +212,7 @@ async def to_code(config): ) cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define("USE_RP2040_CRASH_HANDLER") def add_pio_file(component: str, key: str, data: str): diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 5e5a96c78b1..7079cbca155 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,8 +1,10 @@ #ifdef USE_RP2040 #include "core.h" -#include "crash_handler.h" #include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -25,7 +27,9 @@ void arch_restart() { } void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_read_and_clear(); +#endif #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 6ab46da4449..1f579c2d18e 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -1,5 +1,8 @@ #ifdef USE_RP2040 +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER + #include "crash_handler.h" #include "esphome/core/log.h" @@ -13,13 +16,19 @@ static constexpr uint32_t EF_LR = 5; static constexpr uint32_t EF_PC = 6; -static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD), +// lower 16 bits are the version number. This avoids using a separate scratch +// register for versioning (we only have 8 total). Future firmware reads the +// sentinel to confirm it's crash data, then the version to know the layout. +static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000; +static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION; // We only have 8 scratch registers (32 bytes) that survive watchdog reboot. // Use them for the most important data, then scan the stack for code addresses. // // Scratch register layout: -// [0] = magic (CRASH_MAGIC) +// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version) // [1] = PC (program counter at fault) // [2] = LR (link register from exception frame) // [3] = SP (stack pointer at fault) @@ -57,9 +66,12 @@ static struct { uint8_t backtrace_count; } __attribute__((section(".noinit"))) s_crash_data; +bool crash_handler_has_data() { return s_crash_data.valid; } + void crash_handler_read_and_clear() { s_crash_data.valid = false; - if (watchdog_hw->scratch[0] == CRASH_MAGIC) { + uint32_t magic = watchdog_hw->scratch[0]; + if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { s_crash_data.valid = true; s_crash_data.pc = watchdog_hw->scratch[1]; s_crash_data.lr = watchdog_hw->scratch[2]; @@ -135,7 +147,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame // by a stacking error or corrupted SP, frame may be invalid. Write a minimal // crash marker so we at least know a crash occurred. if (!is_valid_sram_ptr(frame)) { - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = 0; // PC unknown watchdog_hw->scratch[2] = 0; // LR unknown watchdog_hw->scratch[3] = reinterpret_cast(frame); // Record the bad SP for diagnosis @@ -157,7 +169,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame uint32_t pre_fault_sp = reinterpret_cast(post_frame); // Write key registers - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = frame[EF_PC]; watchdog_hw->scratch[2] = frame[EF_LR]; watchdog_hw->scratch[3] = pre_fault_sp; @@ -224,4 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h index f10db47c234..78e8ede08c8 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2040/crash_handler.h @@ -2,7 +2,9 @@ #ifdef USE_RP2040 -#include +#include "esphome/core/defines.h" + +#ifdef USE_RP2040_CRASH_HANDLER namespace esphome::rp2040 { @@ -12,6 +14,10 @@ void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + } // namespace esphome::rp2040 +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a33f10cb9c0..073170aafbf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -338,6 +338,7 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) #define USE_LOOP_PRIORITY +#define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC From 8a5f008aee25d70a9350959cfcbd5de23d2f83e8 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Thu, 12 Mar 2026 02:00:26 -0600 Subject: [PATCH 023/279] [modbus] Fix buffer overflow in modbus (#14719) Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 12 ++--- tests/components/modbus/modbus_test.cpp | 59 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/components/modbus/modbus_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 82672217c56..7a61868e6e9 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -125,13 +125,17 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) { // Byte 0: modbus address (match all) if (at == 0) return true; - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; + // Byte 1: function code + if (at == 1) + return true; // Byte 2: Size (with modbus rtu function code 4/3) // See also https://en.wikipedia.org/wiki/Modbus if (at == 2) return true; + uint8_t address = raw[0]; + uint8_t function_code = raw[1]; + uint8_t data_len = raw[2]; uint8_t data_offset = 3; @@ -146,10 +150,6 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) { // chance that this is a complete message ... admittedly there is a small chance is // isn't but that is quite small given the purpose of the CRC in the first place - // Fewer than 2 bytes can't calc CRC - if (at < 2) - return true; - data_len = at - 2; data_offset = 1; diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp new file mode 100644 index 00000000000..afe5ced082b --- /dev/null +++ b/tests/components/modbus/modbus_test.cpp @@ -0,0 +1,59 @@ +#include +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/helpers.h" + +namespace esphome::modbus { + +// Exposes protected methods for testing. +class TestModbus : public Modbus { + public: + bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } + void test_clear_rx_buffer() { this->rx_buffer_.clear(); } + void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } +}; + +class MockDevice : public ModbusDevice { + public: + void on_modbus_data(const std::vector &data) override { this->data_received = true; } + bool data_received{false}; +}; + +TEST(ModbusTest, TwoByteRegressionTest) { + TestModbus modbus; + modbus.set_role(ModbusRole::CLIENT); + // First byte (at=0) + EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); + // Second byte (at=1) + // This used to reach raw[2] because it skipped the if(at==2) check, causing a + // buffer overflow. + EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); +} + +TEST(ModbusTest, TestValidFrame) { + TestModbus modbus; + modbus.set_role(ModbusRole::CLIENT); + + MockDevice device; + device.set_parent(&modbus); + device.set_address(0x01); + modbus.register_device(&device); + modbus.set_waiting(0x01); + + // Address 1, Function 3, Length 2, Data 0x1234 + uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; + uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); + + std::vector frame; + for (uint8_t b : frame_data) + frame.push_back(b); + frame.push_back(crc & 0xFF); + frame.push_back((crc >> 8) & 0xFF); + + for (size_t i = 0; i < frame.size(); i++) { + bool result = modbus.test_parse_modbus_byte(frame[i]); + EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; + } + EXPECT_TRUE(device.data_received); +} + +} // namespace esphome::modbus From 657890695f8215aeaa4166d6c41b950273538b64 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 12 Mar 2026 03:16:02 -0500 Subject: [PATCH 024/279] [ledc] Fix high-pressure crash & recovery (#14720) --- esphome/components/ledc/ledc_output.cpp | 53 +++++++++++++++++++++++-- esphome/components/ledc/ledc_output.h | 8 ++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 763de851da3..592fc7bd0c1 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -5,6 +5,10 @@ #include #include +#include +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) +#include +#endif #define CLOCK_FREQUENCY 80e6f @@ -16,10 +20,10 @@ static const uint8_t SETUP_ATTEMPT_COUNT_MAX = 5; -namespace esphome { -namespace ledc { +namespace esphome::ledc { static const char *const TAG = "ledc.output"; +static bool ledc_peripheral_reset_done = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const int MAX_RES_BITS = LEDC_TIMER_BIT_MAX - 1; #if SOC_LEDC_SUPPORT_HS_MODE @@ -32,6 +36,28 @@ inline ledc_mode_t get_speed_mode(uint8_t channel) { return channel < 8 ? LEDC_H inline ledc_mode_t get_speed_mode(uint8_t) { return LEDC_LOW_SPEED_MODE; } #endif +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) +// Classic ESP32 (currently the only target without SOC_LEDC_SUPPORT_FADE_STOP) can block in +// ledc_ll_set_duty_start() while duty_start is set. We check the same conf1.duty_start bit here +// to defer updates and avoid entering IDF's unbounded wait loop. +// +// This intentionally depends on the classic ESP32 LEDC register layout used by IDF's own LL HAL. +// If another target without SOC_LEDC_SUPPORT_FADE_STOP is introduced, revisit this helper. +static_assert( +#if defined(CONFIG_IDF_TARGET_ESP32) + true, +#else + false, +#endif + "LEDC duty_start pending check assumes classic ESP32 register layout; " + "re-evaluate for this target"); + +static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { + auto *hw = LEDC_LL_GET_HW(); + return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; +} +#endif + float ledc_max_frequency_for_bit_depth(uint8_t bit_depth) { return static_cast(CLOCK_FREQUENCY) / static_cast(1 << bit_depth); } @@ -105,21 +131,40 @@ void LEDCOutput::write_state(float state) { const uint32_t max_duty = (uint32_t(1) << this->bit_depth_) - 1; const float duty_rounded = roundf(state * max_duty); auto duty = static_cast(duty_rounded); + if (duty == this->last_duty_) { + return; + } + ESP_LOGV(TAG, "Setting duty: %" PRIu32 " on channel %u", duty, this->channel_); auto speed_mode = get_speed_mode(this->channel_); auto chan_num = static_cast(this->channel_ % 8); int hpoint = ledc_angle_to_htop(this->phase_angle_, this->bit_depth_); if (duty == max_duty) { ledc_stop(speed_mode, chan_num, 1); + this->last_duty_ = duty; } else if (duty == 0) { ledc_stop(speed_mode, chan_num, 0); + this->last_duty_ = duty; } else { +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) + if (ledc_duty_update_pending(speed_mode, chan_num)) { + ESP_LOGV(TAG, "Skipping LEDC duty update on channel %u while previous duty_start is still set", this->channel_); + return; + } +#endif ledc_set_duty_with_hpoint(speed_mode, chan_num, duty, hpoint); ledc_update_duty(speed_mode, chan_num); + this->last_duty_ = duty; } } void LEDCOutput::setup() { + if (!ledc_peripheral_reset_done) { + ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); + periph_module_reset(PERIPH_LEDC_MODULE); + ledc_peripheral_reset_done = true; + } + auto speed_mode = get_speed_mode(this->channel_); auto timer_num = static_cast((this->channel_ % 8) / 2); auto chan_num = static_cast(this->channel_ % 8); @@ -207,12 +252,12 @@ void LEDCOutput::update_frequency(float frequency) { this->status_clear_error(); // re-apply duty + this->last_duty_ = UINT32_MAX; this->write_state(this->duty_); } uint8_t next_ledc_channel = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b24e3cfdb23..bf5cdb93055 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -4,11 +4,11 @@ #include "esphome/core/hal.h" #include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" +#include #ifdef USE_ESP32 -namespace esphome { -namespace ledc { +namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; @@ -39,6 +39,7 @@ class LEDCOutput : public output::FloatOutput, public Component { float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; + uint32_t last_duty_{UINT32_MAX}; bool initialized_ = false; }; @@ -56,7 +57,6 @@ template class SetFrequencyAction : public Action { LEDCOutput *parent_; }; -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif From fe2d60ccecf6ceb18def3895a174277037942791 Mon Sep 17 00:00:00 2001 From: Massimo Antonello <31179882+MaxPlap@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:52:58 +0100 Subject: [PATCH 025/279] [one_wire] allow changing address at runtime (#12150) --- esphome/components/one_wire/one_wire.cpp | 5 +++++ esphome/components/one_wire/one_wire.h | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index 187f559ca6a..d14c1c92bdd 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -13,6 +13,11 @@ const std::string &OneWireDevice::get_address_name() { return this->address_name_; } +void OneWireDevice::set_address(uint64_t address) { + this->address_ = address; + this->address_name_.clear(); +} + bool OneWireDevice::send_command_(uint8_t cmd) { if (!this->bus_->select(this->address_)) return false; diff --git a/esphome/components/one_wire/one_wire.h b/esphome/components/one_wire/one_wire.h index f6a956a92c7..324e46cd55d 100644 --- a/esphome/components/one_wire/one_wire.h +++ b/esphome/components/one_wire/one_wire.h @@ -15,7 +15,7 @@ class OneWireDevice { public: /// @brief store the address of the device /// @param address of the device - void set_address(uint64_t address) { this->address_ = address; } + void set_address(uint64_t address); void set_index(uint8_t index) { this->index_ = index; } From c4c19c8a6ca7feaf08f4c30e9153c3d4c87fb0b9 Mon Sep 17 00:00:00 2001 From: Brian Kaufman Date: Thu, 12 Mar 2026 02:07:26 -0700 Subject: [PATCH 026/279] [web_server] use DETAIL_ALL in update_all_json_generator (#14711) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5590e67b822..40830196433 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2181,7 +2181,7 @@ json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *we } json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE); + return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL); } json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson From 511d18577276dcd4b434e82f4abe469c77a0bf47 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 12 Mar 2026 07:56:01 -0500 Subject: [PATCH 027/279] [audio] Bump microOpus to v0.3.5 (#14727) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d95fcf66d7b..b28c2ed3d8c 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -214,4 +214,4 @@ async def to_code(config): cg.add_define("USE_AUDIO_MP3_SUPPORT") if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") - add_idf_component(name="esphome/micro-opus", ref="0.3.4") + add_idf_component(name="esphome/micro-opus", ref="0.3.5") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f7fd3e67bc3..df651ae15dd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/esp-audio-libs: version: 2.0.3 esphome/micro-opus: - version: 0.3.4 + version: 0.3.5 espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: From a76767a0abdc371ad7f3169427cea5b0b83a7594 Mon Sep 17 00:00:00 2001 From: guillempages Date: Thu, 12 Mar 2026 15:15:20 +0100 Subject: [PATCH 028/279] [runtime_image] Update jpegdec lib version (#14726) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- esphome/components/runtime_image/__init__.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ff25675918b..87b4ebb2c69 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -e4b9c4b54e705d3c9400e1cdda8ba0b32634780cfa5f32271832e911bdcafe7e +8e48e836c6fc196d3da000d46eb09db243b87fe33518a74e49c8e009d756074a diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 0773a53d911..7c22bfc9d19 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -74,7 +74,7 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") - cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2") + cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") class PNGFormat(Format): diff --git a/platformio.ini b/platformio.ini index deee23d049c..3c3d62ef76a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,11 +46,11 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.11 ; api improv/Improv@1.2.4 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md From 25c30ac5bb5f32c59663dbeb4946b4f8c5e9d533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20K=C3=B6nig?= Date: Thu, 12 Mar 2026 17:00:08 +0100 Subject: [PATCH 029/279] [mqtt] Fixed permission denied error for client certificates on Windows (#13525) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/mqtt.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index cbf78bd3f6e..ccacbaea54f 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -2,6 +2,7 @@ import contextlib from datetime import datetime import json import logging +import os import ssl import tempfile import time @@ -109,14 +110,18 @@ def prepare( CONF_CLIENT_CERTIFICATE_KEY ): with ( - tempfile.NamedTemporaryFile(mode="w+") as cert_file, - tempfile.NamedTemporaryFile(mode="w+") as key_file, + tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file, + tempfile.NamedTemporaryFile(mode="w+", delete=False) as key_file, ): - cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) - cert_file.flush() - key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) - key_file.flush() - context.load_cert_chain(cert_file.name, key_file.name) + try: + cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) + key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) + cert_file.close() + key_file.close() + context.load_cert_chain(cert_file.name, key_file.name) + finally: + os.unlink(cert_file.name) + os.unlink(key_file.name) client.tls_set_context(context) try: From 07f8ae6c8266ae2f6207463bb3c0bebe4eb5c062 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:14:49 -1000 Subject: [PATCH 030/279] [socket] Fix use-after-free in LWIP PCB close/abort path (#14706) --- .../components/socket/lwip_raw_tcp_impl.cpp | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index fd1b8a95542..1e03a4935c2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -138,13 +138,46 @@ static const char *const TAG = "socket.lwip"; #define LWIP_LOG(msg, ...) #endif +// Clear arg, recv, and err callbacks, then abort a connected PCB. +// Only valid for full tcp_pcb (not tcp_pcb_listen). +// Must be called before destroying the object that tcp_arg points to — +// tcp_abort() triggers the err callback synchronously, which would +// otherwise call back into a partially-destroyed object. +// tcp_sent/tcp_poll are not cleared because this implementation +// never registers them. +static void pcb_detach_abort(struct tcp_pcb *pcb) { + tcp_arg(pcb, nullptr); + tcp_recv(pcb, nullptr); + tcp_err(pcb, nullptr); + tcp_abort(pcb); +} + +// Clear arg, recv, and err callbacks, then gracefully close a connected PCB. +// Only valid for full tcp_pcb (not tcp_pcb_listen). +// After tcp_close(), the PCB remains alive during the TCP close handshake +// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP +// would call recv/err on a destroyed socket object, corrupting the heap. +// tcp_sent/tcp_poll are not cleared because this implementation +// never registers them. +// Returns ERR_OK on success; on failure the PCB is aborted instead. +static err_t pcb_detach_close(struct tcp_pcb *pcb) { + tcp_arg(pcb, nullptr); + tcp_recv(pcb, nullptr); + tcp_err(pcb, nullptr); + err_t err = tcp_close(pcb); + if (err != ERR_OK) { + tcp_abort(pcb); + } + return err; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { LWIP_LOCK(); if (this->pcb_ != nullptr) { LWIP_LOG("tcp_abort(%p)", this->pcb_); - tcp_abort(this->pcb_); + pcb_detach_abort(this->pcb_); this->pcb_ = nullptr; } } @@ -222,15 +255,13 @@ int LWIPRawCommon::close() { return -1; } LWIP_LOG("tcp_close(%p)", this->pcb_); - err_t err = tcp_close(this->pcb_); + err_t err = pcb_detach_close(this->pcb_); + this->pcb_ = nullptr; if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - tcp_abort(this->pcb_); - this->pcb_ = nullptr; errno = err == ERR_MEM ? ENOMEM : EIO; return -1; } - this->pcb_ = nullptr; return 0; } @@ -673,13 +704,10 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { LWIPRawListenImpl::~LWIPRawListenImpl() { LWIP_LOCK(); // Abort any queued PCBs that were never accepted by the main loop. - // Clear the error callback first — tcp_abort triggers it, and we don't - // want s_queued_err_fn writing to slots during destruction. for (uint8_t i = 0; i < this->accepted_socket_count_; i++) { auto &entry = this->accepted_pcbs_[i]; if (entry.pcb != nullptr) { - tcp_err(entry.pcb, nullptr); - tcp_abort(entry.pcb); + pcb_detach_abort(entry.pcb); entry.pcb = nullptr; } if (entry.rx_buf != nullptr) { @@ -691,6 +719,10 @@ LWIPRawListenImpl::~LWIPRawListenImpl() { // Listen PCBs must use tcp_close(), not tcp_abort(). // tcp_abandon() asserts pcb->state != LISTEN and would access // fields that don't exist in the smaller tcp_pcb_listen struct. + // Don't use pcb_detach_close() here — tcp_recv()/tcp_err() also access + // fields that only exist in the full tcp_pcb, not tcp_pcb_listen. + // tcp_close() on a listen PCB is synchronous (frees immediately), + // so there are no async callbacks to worry about. // Close here and null pcb_ so the base destructor skips tcp_abort. if (this->pcb_ != nullptr) { tcp_close(this->pcb_); From a3a88acfcf799a6e0a56051dcdface547642b7aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:04 -1000 Subject: [PATCH 031/279] [socket] Fast path for TCP_NODELAY bypasses lwip_setsockopt overhead (#14693) --- esphome/components/socket/bsd_sockets_impl.h | 11 ++++++++++- esphome/components/socket/lwip_sockets_impl.h | 11 ++++++++++- esphome/core/lwip_fast_select.c | 16 ++++++++++++++++ esphome/core/lwip_fast_select.h | 7 +++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 9ebbe72002b..339a699bc97 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -14,7 +14,7 @@ #endif #ifdef USE_LWIP_FAST_SELECT -struct lwip_sock; +#include "esphome/core/lwip_fast_select.h" #endif namespace esphome::socket { @@ -56,6 +56,15 @@ class BSDSocketImpl { return ::getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) { +#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING) + // Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock, + // bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting). + if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) { + LwIPLock lock; + if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast(optval) != 0)) + return 0; + } +#endif return ::setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return ::listen(this->fd_, backlog); } diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index c5792198635..bfc4da9926a 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -10,7 +10,7 @@ #include "headers.h" #ifdef USE_LWIP_FAST_SELECT -struct lwip_sock; +#include "esphome/core/lwip_fast_select.h" #endif namespace esphome::socket { @@ -52,6 +52,15 @@ class LwIPSocketImpl { return lwip_getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) { +#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING) + // Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock, + // bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting). + if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) { + LwIPLock lock; + if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast(optval) != 0)) + return 0; + } +#endif return lwip_setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return lwip_listen(this->fd_, backlog); } diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index c578a9aae91..a695fa396bc 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -112,6 +112,7 @@ // LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. #include #include +#include // FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not #ifdef USE_ESP32 #include @@ -216,6 +217,21 @@ void esphome_lwip_hook_socket(struct lwip_sock *sock) { sock->conn->callback = esphome_socket_event_callback; } +bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable) { + if (sock == NULL || sock->conn == NULL) + return false; + if (NETCONNTYPE_GROUP(sock->conn->type) != NETCONN_TCP) + return false; + if (sock->conn->pcb.tcp == NULL) + return false; + if (enable) { + tcp_nagle_disable(sock->conn->pcb.tcp); + } else { + tcp_nagle_enable(sock->conn->pcb.tcp); + } + return true; +} + // Wake the main loop from another FreeRTOS task. NOT ISR-safe. void esphome_lwip_wake_main_loop(void) { TaskHandle_t task = s_main_loop_task; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 46c6b711cd2..50706ba9f69 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -66,6 +66,13 @@ void esphome_lwip_wake_main_loop(void); /// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken); +/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. +/// Must be called with the TCPIP core lock held (LwIPLock in C++). +/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, +/// hooks, refcounting) — just a direct pcb->flags bit set/clear. +/// Returns true if successful, false if sock/conn/pcb is NULL or the socket is not TCP. +bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable); + /// Wake the main loop task from any context (ISR, thread, or main loop). /// ESP32-only: uses xPortInIsrContext() to detect ISR context. /// LibreTiny lacks IRAM_ATTR support needed for ISR-safe paths. From 03c091adfcf1981c2259715e563d84b8f5210935 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:21 -1000 Subject: [PATCH 032/279] [esp32_ble_client] Fix disconnect race that causes stuck connections (#14211) Co-authored-by: Claude Opus 4.6 --- .../esp32_ble_client/ble_client_base.cpp | 43 ++++++++++++++++--- .../esp32_ble_client/ble_client_base.h | 17 +++++++- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2f17334c77c..9d6e079d926 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -27,6 +27,7 @@ static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, .uuid = @@ -62,6 +63,15 @@ void BLEClientBase::loop() { // will enable it again when a connection is needed. else if (this->state() == espbt::ClientState::IDLE) { this->disable_loop(); + } else if (this->state() == espbt::ClientState::DISCONNECTING && + (millis() - this->disconnecting_started_) > DISCONNECTING_TIMEOUT) { + ESP_LOGE(TAG, "[%d] [%s] Timeout waiting for CLOSE_EVT after disconnect, forcing IDLE", this->connection_index_, + this->address_str_); + // release_services() must be called before set_idle_() — if we entered DISCONNECTING + // via unconditional_disconnect() (which doesn't call release_services()), and ESP-IDF + // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. + this->release_services(); + this->set_idle_(); } } @@ -101,12 +111,16 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { #endif void BLEClientBase::connect() { - // Prevent duplicate connection attempts + // Prevent duplicate connection attempts or connecting while still disconnecting if (this->state() == espbt::ClientState::CONNECTING || this->state() == espbt::ClientState::CONNECTED || this->state() == espbt::ClientState::ESTABLISHED) { ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state())); return; + } else if (this->state() == espbt::ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] [%s] Cannot connect, still waiting for CLOSE_EVT to complete disconnect", + this->connection_index_, this->address_str_); + return; } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; @@ -174,7 +188,7 @@ void BLEClientBase::unconditional_disconnect() { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { - this->set_state(espbt::ClientState::DISCONNECTING); + this->set_disconnecting_(); } } @@ -220,6 +234,7 @@ void BLEClientBase::log_connection_params_(const char *param_type) { void BLEClientBase::handle_connection_result_(esp_err_t ret) { if (ret) { this->log_gattc_warning_("esp_ble_gattc_open", ret); + // Don't use set_idle_() here — CONNECT_EVT never fired so conn_id_ is still UNSET_CONN_ID. this->set_state(espbt::ClientState::IDLE); } } @@ -311,15 +326,16 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); - this->set_state(espbt::ClientState::IDLE); + // Connection was never established so CLOSE_EVT may not follow + this->set_idle_(); break; } if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have // this->conn_id_ set, we can disconnect it. + // Don't reset conn_id_ here — CLOSE_EVT needs it to match and call set_idle_(). this->unconditional_disconnect(); - this->conn_id_ = UNSET_CONN_ID; break; } // MTU negotiation already started in ESP_GATTC_CONNECT_EVT @@ -363,8 +379,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_, param->disconnect.reason); } + // For active disconnects (esp_ble_gattc_close), CLOSE_EVT arrives before + // DISCONNECT_EVT. If CLOSE_EVT already transitioned us to IDLE, don't go + // backwards to DISCONNECTING — the connection is already fully cleaned up. + if (this->state() == espbt::ClientState::IDLE) { + this->log_event_("DISCONNECT_EVT after CLOSE_EVT, already IDLE"); + break; + } + // For passive disconnects (remote device disconnected or link lost), + // DISCONNECT_EVT arrives first. Don't transition to IDLE yet — wait for + // CLOSE_EVT to ensure the controller has fully freed resources (L2CAP + // channels, ATT resources, HCI connection handle). Transitioning to IDLE + // here would allow reconnection before cleanup is complete, causing the + // controller to reject the new connection (status=133) or crash with + // ASSERT_PARAM in lld_evt.c. this->release_services(); - this->set_state(espbt::ClientState::IDLE); + this->set_disconnecting_(); break; } @@ -387,8 +417,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ return false; this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); - this->set_state(espbt::ClientState::IDLE); - this->conn_id_ = UNSET_CONN_ID; + this->set_idle_(); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index af4f1b30290..4e0b22cc299 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -113,11 +113,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; esp_bd_addr_t remote_bda_; // 6 bytes - // Group 5: 2-byte types + // Group 5: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 6: 2-byte types uint16_t conn_id_{UNSET_CONN_ID}; uint16_t mtu_{23}; - // Group 6: 1-byte types and small enums + // Group 7: 1-byte types and small enums esp_ble_addr_type_t remote_addr_type_{BLE_ADDR_TYPE_PUBLIC}; espbt::ConnectionType connection_type_{espbt::ConnectionType::V1}; uint8_t connection_index_; @@ -137,6 +140,16 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Transition to IDLE and reset conn_id — call when the connection is fully dead. + void set_idle_() { + this->set_state(espbt::ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + } + /// Transition to DISCONNECTING and start the safety timeout. + void set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(espbt::ClientState::DISCONNECTING); + } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From fd1d0167951b7b228f6c13c2ce463059a2636417 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:34 -1000 Subject: [PATCH 033/279] [time] Fix settimeofday() failure on ESP8266 (#14707) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/time/real_time_clock.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 566344fa880..4e623942ac5 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -88,16 +88,16 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { struct timeval timev { .tv_sec = static_cast(epoch), .tv_usec = 0, }; +#ifdef USE_ESP8266 + // ESP8266 settimeofday() requires tz to be nullptr + int ret = settimeofday(&timev, nullptr); +#else struct timezone tz = {0, 0}; int ret = settimeofday(&timev, &tz); - if (ret != 0 && errno == EINVAL) { - // Some ESP8266 frameworks abort when timezone parameter is not NULL - // while ESP32 expects it not to be NULL - ret = settimeofday(&timev, nullptr); - } +#endif if (ret != 0) { - ESP_LOGW(TAG, "setimeofday() failed with code %d", ret); + ESP_LOGW(TAG, "settimeofday() failed with code %d", ret); } #endif auto time = this->now(); From 4a21afe7ce056115b7af94ccb02c7bc5bd3d8f36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:48 -1000 Subject: [PATCH 034/279] [ota][socket] Fix ESP8266/RP2040 OTA timeout by using SO_RCVTIMEO instead of polling (#14675) --- .../components/esphome/ota/ota_esphome.cpp | 46 +++++++++++- esphome/components/socket/headers.h | 2 + .../components/socket/lwip_raw_tcp_impl.cpp | 71 +++++++++++++++++-- esphome/components/socket/lwip_raw_tcp_impl.h | 16 +++-- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index a1cdf59d2b7..d8dbe2dee2d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -18,6 +18,7 @@ #include #include +#include namespace esphome { @@ -238,6 +239,31 @@ void ESPHomeOTAComponent::handle_data_() { /// and reboots on success. /// /// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ. + /// + /// Socket I/O strategy: + /// + /// Before this function, the handshake states use non-blocking I/O: + /// read()/write() return immediately with EWOULDBLOCK if no data + /// loop() retries on next iteration (~16ms), no delay needed + /// + /// This function switches to blocking mode with SO_RCVTIMEO/SO_SNDTIMEO: + /// + /// Path | Wait mechanism | WDT strategy + /// --------------|------------------------|--------------------------- + /// Main read | SO_RCVTIMEO (2s block) | feed_wdt() only, no delay + /// readall_() | SO_RCVTIMEO (2s block) | feed_wdt() + delay(0) + /// writeall_() | SO_SNDTIMEO (2s block) | feed_wdt() + delay(1) + /// + /// readall_() uses delay(0) because SO_RCVTIMEO already waited — just yield. + /// writeall_() uses delay(1) because on raw TCP (ESP8266, RP2040) writes + /// never block (tcp_write returns immediately), so delay(1) prevents spinning. + /// + /// Platform details: + /// BSD sockets (ESP32): setblocking(true) makes read/write block + /// lwip sockets (LT): setblocking(true) makes read/write block + /// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses + /// socket_delay()/socket_wake() in read(); + /// write() always returns immediately ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; @@ -249,6 +275,14 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif + // Set socket timeouts and blocking mode (see strategy table above) + struct timeval tv; + tv.tv_sec = 2; + tv.tv_usec = 0; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + this->client_->setblocking(true); + // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); @@ -299,7 +333,8 @@ void ESPHomeOTAComponent::handle_data_() { ssize_t read = this->client_->read(buf, requested); if (read == -1) { if (this->would_block_(errno)) { - this->yield_and_feed_watchdog_(); + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); continue; } ESP_LOGW(TAG, "Read err %d", errno); @@ -401,7 +436,9 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { } else { at += read; } - this->yield_and_feed_watchdog_(); + // read() already waited via SO_RCVTIMEO, just yield without 1ms stall + App.feed_wdt(); + delay(0); } return true; @@ -422,10 +459,13 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, errno); return false; } + // EWOULDBLOCK: on raw TCP writes never block, delay(1) prevents spinning + this->yield_and_feed_watchdog_(); } else { at += written; + // write() may block up to SO_SNDTIMEO on BSD/lwip sockets, feed WDT + App.feed_wdt(); } - this->yield_and_feed_watchdog_(); } return true; } diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 16e4d23d3ba..0eece6480f6 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -51,6 +51,8 @@ #define SO_REUSEADDR 0x0004 /* Allow local address reuse */ #define SO_KEEPALIVE 0x0008 /* keep connections alive */ #define SO_BROADCAST 0x0020 /* permit to send and to receive broadcast messages (see IP_SOF_BROADCAST option) */ +#define SO_RCVTIMEO 0x1006 /* receive timeout */ +#define SO_SNDTIMEO 0x1005 /* send timeout */ #define SOL_SOCKET 0xfff /* options for socket level */ diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 1e03a4935c2..96328e68c73 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -81,7 +82,9 @@ void socket_delay(uint32_t ms) { s_socket_woke = false; return; } - s_socket_woke = false; + // Don't clear s_socket_woke here — if an IRQ fires between the check above + // and the while loop below, the while condition sees it immediately. Clearing + // here would lose that wake and sleep until the timer fires. s_delay_expired = false; // Set a one-shot timer to wake us after the timeout. // add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error. @@ -99,6 +102,7 @@ void socket_delay(uint32_t ms) { // Cancel timer if we woke early (socket data arrived before timeout) if (!s_delay_expired) cancel_alarm(alarm); + s_socket_woke = false; // consume the wake for next call } // No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP @@ -359,6 +363,18 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o *optlen = 4; return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (*optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + uint32_t ms = this->recv_timeout_cs_ * 10; + auto *tv = reinterpret_cast(optval); + tv->tv_sec = ms / 1000; + tv->tv_usec = (ms % 1000) * 1000; + *optlen = sizeof(struct timeval); + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (*optlen < 4) { errno = EINVAL; @@ -388,6 +404,21 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle // to prevent warnings return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + const auto *tv = reinterpret_cast(optval); + uint32_t ms = tv->tv_sec * 1000 + tv->tv_usec / 1000; + uint32_t cs = (ms + 9) / 10; // round up to nearest centisecond + this->recv_timeout_cs_ = cs > 255 ? 255 : static_cast(cs); + return 0; + } + if (level == SOL_SOCKET && optname == SO_SNDTIMEO) { + // Raw TCP writes are non-blocking (tcp_write), so send timeout is a no-op. + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (optlen != 4) { errno = EINVAL; @@ -518,8 +549,25 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { return ERR_OK; } -ssize_t LWIPRawImpl::read(void *buf, size_t len) { - LWIP_LOCK(); +void LWIPRawImpl::wait_for_data_() { + // Wait for data without holding LWIP_LOCK so recv_fn() can run on RP2040 + // (needs async_context lock). + // + // Loop until data arrives, connection closes, or the full timeout elapses. + // socket_delay() may return early due to other sockets waking the global + // socket_wake() flag, so we re-enter for the remaining time. + uint32_t timeout_ms = this->recv_timeout_cs_ * 10; + uint32_t start = millis(); + while (this->waiting_for_data_()) { + uint32_t elapsed = millis() - start; + if (elapsed >= timeout_ms) + break; + socket_delay(timeout_ms - elapsed); + } +} + +ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { + // Caller must hold LWIP_LOCK. Copies available data from rx_buf_ into buf. if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -578,11 +626,26 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { return read; } +ssize_t LWIPRawImpl::read(void *buf, size_t len) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + + LWIP_LOCK(); + return this->read_locked_(buf, len); +} + ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t ret = 0; for (int i = 0; i < iovcnt; i++) { - ssize_t err = this->read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); + ssize_t err = this->read_locked_(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { if (ret != 0) { // if we already read some don't return an error diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 95931afcf3f..3c27d71062f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -57,6 +57,7 @@ class LWIPRawCommon { // instead use it for determining whether to call lwip_output bool nodelay_ = false; sa_family_t family_ = 0; + uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s) }; /// Connected socket implementation for LWIP raw TCP. @@ -107,11 +108,8 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ECONNRESET; return -1; } - if (blocking) { - // blocking operation not supported - errno = EINVAL; - return -1; - } + // Raw TCP doesn't use a blocking flag directly. Blocking behavior + // is provided by SO_RCVTIMEO which makes read() wait via socket_delay(). return 0; } int loop() { return 0; } @@ -122,6 +120,14 @@ class LWIPRawImpl : public LWIPRawCommon { static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); protected: + // True when the socket could receive data but none has arrived yet. + // Safe to call without LWIP_LOCK — only null-checks pointers and reads a bool, + // all atomic on ARM/Xtensa. A stale value is harmless: the caller either does + // an unnecessary wait (stale true) or skips it (stale false), and the + // authoritative recheck happens under LWIP_LOCK afterward. + bool waiting_for_data_() const { return this->rx_buf_ == nullptr && !this->rx_closed_ && this->pcb_ != nullptr; } + void wait_for_data_(); + ssize_t read_locked_(void *buf, size_t len); ssize_t internal_write_(const void *buf, size_t len); int internal_output_(); From 70d188202a1ddca4a3a342ca332f2d0c13a47588 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:08 -1000 Subject: [PATCH 035/279] [adc] Fix PICO_VSYS_PIN compile error on RP2350 boards (#14724) --- esphome/components/adc/adc_sensor_rp2040.cpp | 7 +++++++ tests/components/adc/test.rp2040-pico2-ard.yaml | 11 +++++++++++ tests/components/spi/test.rp2040-pico2-ard.yaml | 6 ++++++ .../build_components_base.rp2040-pico2-ard.yaml | 15 +++++++++++++++ .../common/spi/rp2040-pico2-ard.yaml | 12 ++++++++++++ 5 files changed, 51 insertions(+) create mode 100644 tests/components/adc/test.rp2040-pico2-ard.yaml create mode 100644 tests/components/spi/test.rp2040-pico2-ard.yaml create mode 100644 tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml create mode 100644 tests/test_build_components/common/spi/rp2040-pico2-ard.yaml diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8496e0f41e4..a79707e2347 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -8,6 +8,13 @@ #endif // CYW43_USES_VSYS_PIN #include +// PICO_VSYS_PIN is defined in pico-sdk board headers (e.g. boards/pico2.h), +// but the Arduino framework's config_autogen.h includes a generic board header +// that doesn't define it. Provide the standard value (pin 29) as a fallback. +#ifndef PICO_VSYS_PIN +#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage) +#endif + namespace esphome { namespace adc { diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..4cc865bb5d3 --- /dev/null +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -0,0 +1,11 @@ +sensor: + - id: my_sensor + platform: adc + pin: VCC + name: ADC Test sensor + update_interval: "1:01" + unit_of_measurement: "°C" + icon: "mdi:water-percent" + accuracy_decimals: 5 + setup_priority: -100 + force_update: true diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..81a8acafd88 --- /dev/null +++ b/tests/components/spi/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + clk_pin: GPIO2 + mosi_pin: GPIO3 + miso_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..0922a5238e8 --- /dev/null +++ b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestrp2040pico2ard + friendly_name: $component_name + +rp2040: + board: rpipico2 + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..205beb6e1bb --- /dev/null +++ b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml @@ -0,0 +1,12 @@ +# Common SPI configuration for RP2040 Pico 2 (RP2350) Arduino tests + +substitutions: + clk_pin: GPIO18 + mosi_pin: GPIO19 + miso_pin: GPIO16 + +spi: + - id: spi_bus + clk_pin: ${clk_pin} + mosi_pin: ${mosi_pin} + miso_pin: ${miso_pin} From 618312f0ee0944a9575b58bc9eb62fb929755fa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:23 -1000 Subject: [PATCH 036/279] [api] Fix undefined behavior in noise handshake with empty rx buffer (#14722) --- esphome/components/api/api_frame_helper_noise.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 3e6ecf9dc30..f945253c89d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -258,10 +258,13 @@ APIError APINoiseFrameHelper::state_action_() { // ignore contents, may be used in future for flags // Resize for: existing prologue + 2 size bytes + frame data size_t old_size = this->prologue_.size(); - this->prologue_.resize(old_size + 2 + this->rx_buf_.size()); - this->prologue_[old_size] = (uint8_t) (this->rx_buf_.size() >> 8); - this->prologue_[old_size + 1] = (uint8_t) this->rx_buf_.size(); - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), this->rx_buf_.size()); + size_t rx_size = this->rx_buf_.size(); + this->prologue_.resize(old_size + 2 + rx_size); + this->prologue_[old_size] = (uint8_t) (rx_size >> 8); + this->prologue_[old_size + 1] = (uint8_t) rx_size; + if (rx_size > 0) { + std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + } state_ = State::SERVER_HELLO; } From 186ca4e458cdc16cf1ee9fa692b9dbe066d57b1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:38 -1000 Subject: [PATCH 037/279] [uart] Allow hardware UART with single pin on RP2040 (#14725) --- .../components/uart/uart_component_rp2040.cpp | 37 +++++++++++++++---- tests/components/uart/test.rp2040-ard.yaml | 3 ++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index 858f1a02ddf..6f6f1fb96b7 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -105,15 +105,34 @@ void RP2040UartComponent::setup() { } } + // Determine which hardware UART to use. A pin that is not specified + // should not prevent hardware UART selection — one-way UART is valid. + // When both pins are configured, both must be HW-capable and agree on UART number. + // When only one pin is configured (nullptr other), use that pin's HW UART. + // If a pin is configured but not HW-capable (inverted/invalid), fall back to SerialPIO. + int8_t hw_uart = -1; + const bool tx_configured = (this->tx_pin_ != nullptr); + const bool rx_configured = (this->rx_pin_ != nullptr); + + if (tx_configured && rx_configured) { + // Both pins configured — both must map to the same hardware UART + if (tx_hw != -1 && rx_hw != -1 && tx_hw == rx_hw) { + hw_uart = tx_hw; + } + } else if (tx_configured) { + hw_uart = tx_hw; + } else if (rx_configured) { + hw_uart = rx_hw; + } + #ifdef USE_LOGGER - if (tx_hw == rx_hw && logger::global_logger->get_uart() == tx_hw) { - ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", tx_hw); - tx_hw = -1; - rx_hw = -1; + if (hw_uart != -1 && logger::global_logger->get_uart() == hw_uart) { + ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", hw_uart); + hw_uart = -1; } #endif - if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) { + if (hw_uart == -1) { ESP_LOGV(TAG, "Using SerialPIO"); pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin(); pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin(); @@ -127,13 +146,15 @@ void RP2040UartComponent::setup() { } else { ESP_LOGV(TAG, "Using Hardware Serial"); SerialUART *serial; - if (tx_hw == 0) { + if (hw_uart == 0) { serial = &Serial1; } else { serial = &Serial2; } - serial->setTX(this->tx_pin_->get_pin()); - serial->setRX(this->rx_pin_->get_pin()); + if (this->tx_pin_ != nullptr) + serial->setTX(this->tx_pin_->get_pin()); + if (this->rx_pin_ != nullptr) + serial->setRX(this->rx_pin_->get_pin()); serial->setFIFOSize(this->rx_buffer_size_); serial->begin(this->baud_rate_, config); this->serial_ = serial; diff --git a/tests/components/uart/test.rp2040-ard.yaml b/tests/components/uart/test.rp2040-ard.yaml index 5eb2b533ea0..1d5f91c6a7d 100644 --- a/tests/components/uart/test.rp2040-ard.yaml +++ b/tests/components/uart/test.rp2040-ard.yaml @@ -23,3 +23,6 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_rx_only + rx_pin: 17 + baud_rate: 1200 From 05d285ba861572e5af149676dfe763edc39129c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:53 -1000 Subject: [PATCH 038/279] [api] Fix heap-buffer-overflow in protobuf message dump for StringRef (#14721) --- esphome/components/api/api_pb2_dump.cpp | 2 +- script/api_protobuf/api_protobuf.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 740bf2e47fd..5a53f0281fa 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -13,7 +13,7 @@ namespace esphome::api { static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) { out.append("'"); if (!ref.empty()) { - out.append(ref.c_str()); + out.append(ref.c_str(), ref.size()); } out.append("'"); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index b4044c362c6..dff6c7690a0 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -642,7 +642,7 @@ class StringType(TypeInfo): # For SOURCE_BOTH, check if StringRef is set (sending) or use string (received) return ( f"if (!this->{self.field_name}_ref_.empty()) {{" - f' out.append("\'").append(this->{self.field_name}_ref_.c_str()).append("\'");' + f' out.append("\'").append(this->{self.field_name}_ref_.c_str(), this->{self.field_name}_ref_.size()).append("\'");' f"}} else {{" f' out.append("\'").append(this->{self.field_name}).append("\'");' f"}}" @@ -2705,7 +2705,7 @@ namespace esphome::api { static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) { out.append("'"); if (!ref.empty()) { - out.append(ref.c_str()); + out.append(ref.c_str(), ref.size()); } out.append("'"); } From 25c74c8f99bb1948d9ceece698f4d8d5a80a8a53 Mon Sep 17 00:00:00 2001 From: Brian Kaufman Date: Thu, 12 Mar 2026 16:23:29 -0700 Subject: [PATCH 039/279] [OTA] Stage exact uploaded size for ESP8266 web OTA (gzip fix) (#14741) --- esphome/components/ota/ota_backend_esp8266.cpp | 7 +++++-- esphome/components/ota/ota_backend_esp8266.h | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 1f9a77e4261..93e6249fb3d 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -105,6 +105,7 @@ OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { this->current_address_ = this->start_address_; this->image_size_ = image_size; + this->bytes_received_ = 0; this->buffer_len_ = 0; this->md5_set_ = false; @@ -140,6 +141,7 @@ OTAResponseTypes ESP8266OTABackend::write(uint8_t *data, size_t len) { size_t to_buffer = std::min(len - written, this->buffer_size_ - this->buffer_len_); memcpy(this->buffer_.get() + this->buffer_len_, data + written, to_buffer); this->buffer_len_ += to_buffer; + this->bytes_received_ += to_buffer; written += to_buffer; // If buffer is full, write to flash @@ -252,8 +254,8 @@ OTAResponseTypes ESP8266OTABackend::end() { } } - // Calculate actual bytes written - size_t actual_size = this->current_address_ - this->start_address_; + // Calculate actual bytes written (exact uploaded size, excluding flash write padding) + size_t actual_size = this->bytes_received_; // Check if any data was written if (actual_size == 0) { @@ -304,6 +306,7 @@ void ESP8266OTABackend::abort() { this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; + this->bytes_received_ = 0; esp8266::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 6213289accb..b364e216a36 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -48,6 +48,7 @@ class ESP8266OTABackend final { uint32_t start_address_{0}; uint32_t current_address_{0}; size_t image_size_{0}; + size_t bytes_received_{0}; md5::MD5Digest md5_{}; uint8_t expected_md5_[16]; // Fixed-size buffer for 128-bit (16-byte) MD5 digest From fd8e510745542d097e2ab0dcc36352b428cd5f4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 13:28:25 -1000 Subject: [PATCH 040/279] [light] Fix ambiguous set_effect overload for const char* (#14732) --- .../addressable_light/addressable_light_display.h | 2 +- esphome/components/light/light_call.cpp | 2 +- esphome/components/light/light_call.h | 2 ++ tests/components/light/common.yaml | 6 ++++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 53f8604b7de..d9b8680547f 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -33,7 +33,7 @@ class AddressableLightDisplay : public display::DisplayBuffer { // - Save the current effect index. this->last_effect_index_ = light_state_->get_current_effect_index(); // - Disable any current effect. - light_state_->make_call().set_effect(0).perform(); + light_state_->make_call().set_effect(uint32_t{0}).perform(); } } enabled_ = enabled; diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 14cd0e92f69..cd45994f625 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -506,7 +506,7 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { LightCall &LightCall::set_effect(const char *effect, size_t len) { if (len == 4 && strncasecmp(effect, "none", 4) == 0) { - this->set_effect(0); + this->set_effect(uint32_t{0}); return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 0926ab6108e..0eb1785239c 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -130,6 +130,8 @@ class LightCall { LightCall &set_effect(optional effect); /// Set the effect of the light by its name. LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name (const char * overload to resolve ambiguity). + LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); } /// Set the effect of the light by its name and length (zero-copy from API). LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e5fab62a793..e1216e7b60b 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -60,6 +60,12 @@ esphome: } } + # Test set_effect with const char* doesn't cause ambiguous overload (issue #14728) + - lambda: |- + auto call = id(test_monochromatic_light).turn_on(); + call.set_effect("None"); + call.perform(); + - light.toggle: test_binary_light - light.turn_off: test_rgb_light - light.turn_on: From 7bb4e754591a4b3e56a6398dfeda1a0113e2ee81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:47:16 -1000 Subject: [PATCH 041/279] [rp2040] Use full flash for sketch in testing mode (#14747) Co-authored-by: Claude Opus 4.6 --- esphome/components/rp2040/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 276187b273c..71e5f1488cb 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -203,7 +203,12 @@ async def to_code(config): cg.add_build_flag(f"-Wl,--wrap={symbol}") cg.add_platformio_option("board_build.core", "earlephilhower") - cg.add_platformio_option("board_build.filesystem_size", "1m") + # In testing mode, use all flash for sketch to allow linking grouped component tests. + # Real RP2040 hardware uses 1MB filesystem + 1MB sketch, but CI tests may combine + # many components that exceed the 1MB sketch partition. + cg.add_platformio_option( + "board_build.filesystem_size", "0m" if CORE.testing_mode else "1m" + ) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] cg.add_define( From 2ca13972b939a551278d6064c98d6660be69440a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:06 -1000 Subject: [PATCH 042/279] [debug] Fix missing reset reason for RP2040/RP2350 (#14740) --- esphome/components/debug/debug_rp2040.cpp | 64 +++++++++++++++++++++-- esphome/core/helpers.h | 22 ++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index c9d41942dbc..8dc84a26732 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -1,23 +1,81 @@ #include "debug_component.h" #ifdef USE_RP2040 +#include "esphome/core/defines.h" #include "esphome/core/log.h" #include +#include +#if defined(PICO_RP2350) +#include +#else +#include +#endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif namespace esphome { namespace debug { static const char *const TAG = "debug"; -const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_reset_reason_(std::span buffer) { + char *buf = buffer.data(); + const size_t size = RESET_REASON_BUFFER_SIZE; + size_t pos = 0; + +#if defined(PICO_RP2350) + uint32_t chip_reset = powman_hw->chip_reset; + if (chip_reset & 0x04000000) // HAD_GLITCH_DETECT + pos = buf_append_str(buf, size, pos, "Power supply glitch|"); + if (chip_reset & 0x00040000) // HAD_RUN_LOW + pos = buf_append_str(buf, size, pos, "RUN pin|"); + if (chip_reset & 0x00020000) // HAD_BOR + pos = buf_append_str(buf, size, pos, "Brown-out|"); + if (chip_reset & 0x00010000) // HAD_POR + pos = buf_append_str(buf, size, pos, "Power-on reset|"); +#else + uint32_t chip_reset = vreg_and_chip_reset_hw->chip_reset; + if (chip_reset & 0x00010000) // HAD_RUN + pos = buf_append_str(buf, size, pos, "RUN pin|"); + if (chip_reset & 0x00000100) // HAD_POR + pos = buf_append_str(buf, size, pos, "Power-on reset|"); +#endif + + if (watchdog_caused_reboot()) { + bool handled = false; +#ifdef USE_RP2040_CRASH_HANDLER + if (rp2040::crash_handler_has_data()) { + pos = buf_append_str(buf, size, pos, "Crash (HardFault)|"); + handled = true; + } +#endif + if (!handled) { + if (watchdog_enable_caused_reboot()) { + pos = buf_append_str(buf, size, pos, "Watchdog timeout|"); + } else { + pos = buf_append_str(buf, size, pos, "Software reset|"); + } + } + } + + // Remove trailing '|' + if (pos > 0 && buf[pos - 1] == '|') { + buf[pos - 1] = '\0'; + } else if (pos == 0) { + return "Unknown"; + } + + return buf; +} const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return rp2040.getFreeHeap(); } +uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = rp2040.f_cpu(); + uint32_t cpu_freq = ::rp2040.f_cpu(); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 70ac1574f0c..b2517e2d7ac 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -942,6 +942,28 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, } #endif +/// Safely append a string to buffer without format parsing, returning new position (capped at size). +/// More efficient than buf_append_printf for plain string literals. +/// @param buf Output buffer +/// @param size Total buffer size +/// @param pos Current position in buffer +/// @param str String to append (must not be null) +/// @return New position after appending (capped at size on overflow) +inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str) { + if (pos >= size) { + return size; + } + size_t remaining = size - pos - 1; // reserve space for null terminator + size_t len = strlen(str); + if (len > remaining) { + len = remaining; + } + memcpy(buf + pos, str, len); + pos += len; + buf[pos] = '\0'; + return pos; +} + /// Concatenate a name with a separator and suffix using an efficient stack-based approach. /// This avoids multiple heap allocations during string construction. /// Maximum name length supported is 120 characters for friendly names. From e15b19b2237739c945010d8addb3108b06733219 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:29 -1000 Subject: [PATCH 043/279] [captive_portal] Fix captive portal inaccessible when web_server auth is configured (#14734) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/captive_portal/captive_portal.cpp | 4 ++-- esphome/components/web_server_base/web_server_base.cpp | 4 ++++ esphome/components/web_server_base/web_server_base.h | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5af6ab29a2e..183f16c5f84 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -61,7 +61,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { // Defer save to main loop thread to avoid NVS operations from HTTP thread this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid.c_str(), psk.c_str()); }); #endif - request->redirect(ESPHOME_F("/?save")); + request->send(200, ESPHOME_F("text/plain"), ESPHOME_F("Saved. Connecting...")); } void CaptivePortal::setup() { @@ -71,7 +71,7 @@ void CaptivePortal::setup() { void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { - this->base_->add_handler(this); + this->base_->add_handler_without_auth(this); } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index dbbcd10d8df..3e1baf34bad 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -11,6 +11,10 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif + this->add_handler_without_auth(handler); +} + +void WebServerBase::add_handler_without_auth(AsyncWebHandler *handler) { this->handlers_.push_back(handler); if (this->server_ != nullptr) { this->server_->addHandler(handler); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 54421c851e5..48e13ad71e2 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -122,6 +122,14 @@ class WebServerBase { #endif void add_handler(AsyncWebHandler *handler); + /** + * WARNING: Registers a handler that bypasses the USE_WEBSERVER_AUTH middleware. + * + * This should only be used for endpoints that are intentionally unauthenticated + * (for example, captive portal or very limited-status endpoints). For normal + * endpoints that should respect web server authentication, use add_handler(). + */ + void add_handler_without_auth(AsyncWebHandler *handler); void set_port(uint16_t port) { port_ = port; } uint16_t get_port() const { return port_; } From 89719cf4b2490e068c057db61f1dce782839f7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:41 -1000 Subject: [PATCH 044/279] [water_heater] Set OPERATION_MODE feature flag when modes are configured (#14748) --- .../template/water_heater/template_water_heater.cpp | 1 + tests/integration/test_water_heater_template.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 73081d204b4..092df6fdca3 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -26,6 +26,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { if (!this->supported_modes_.empty()) { traits.set_supported_modes(this->supported_modes_); + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE); } traits.set_supports_current_temperature(true); diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 096d4c84615..d63d1d69845 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -102,7 +102,11 @@ async def test_water_heater_template( f"Expected target temp 60.0, got {initial_state.target_temperature}" ) - # Verify supported features: away mode and on/off (fixture has away + is_on lambdas) + # Verify supported features: operation mode, away mode, and on/off + assert ( + test_water_heater.supported_features + & WaterHeaterFeature.SUPPORTS_OPERATION_MODE + ) != 0, "Expected SUPPORTS_OPERATION_MODE in supported_features" assert ( test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE ) != 0, "Expected SUPPORTS_AWAY_MODE in supported_features" From 22b25724ae23dc19982baef702fad6061eb544c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:55 -1000 Subject: [PATCH 045/279] [wifi] Reject EAP/WPA2 Enterprise config on unsupported platforms (#14746) --- esphome/components/wifi/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 2808d313111..480ccd65c54 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -166,6 +166,7 @@ TTLS_PHASE_2 = { } EAP_AUTH_SCHEMA = cv.All( + cv.only_on([Platform.ESP32, Platform.ESP8266]), cv.Schema( { cv.Optional(CONF_IDENTITY): cv.string_strict, From 7e8e085a040a946906085661253c8a8ba693f19f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:49:07 -1000 Subject: [PATCH 046/279] [light] Fix binary light spamming 'brightness not supported' warning with strobe effect (#14735) --- esphome/components/light/light_call.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index cd45994f625..0b2d391fd6e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -214,7 +214,14 @@ LightColorValues LightCall::validate_() { if (this->has_brightness() && this->brightness_ == 0.0f) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - this->brightness_ = 1.0f; + if (color_mode & ColorCapability::BRIGHTNESS) { + // Reset brightness so the light has nonzero brightness when turned back on. + this->brightness_ = 1.0f; + } else { + // Light doesn't support brightness; clear the flag to avoid a spurious + // "brightness not supported" warning during capability validation. + this->clear_flag_(FLAG_HAS_BRIGHTNESS); + } } // Set color brightness to 100% if currently zero and a color is set. From 59c1368440f13bdc6baa0f40a21edfd7f99d71db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:53:46 -1000 Subject: [PATCH 047/279] [i2c] Fix RP2040 I2C bus selection based on pin assignment (#14745) --- esphome/components/i2c/__init__.py | 37 ++++++++++++++++++++++ esphome/components/i2c/i2c_bus_arduino.cpp | 10 +++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index de3f2be6740..1684f479ba3 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -93,11 +93,31 @@ def _bus_declare_type(value): raise NotImplementedError +def _rp2040_i2c_controller(pin): + """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. + + See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): + https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf + See RP2350 datasheet Table 7 (section 9.4, "Function Select"): + https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf + """ + return (pin // 2) % 2 + + def validate_config(config): if CORE.is_esp32: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) + if CORE.is_rp2040: + sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) + scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) + if sda_controller != scl_controller: + raise cv.Invalid( + f"SDA pin GPIO{config[CONF_SDA]} is on I2C{sda_controller} but " + f"SCL pin GPIO{config[CONF_SCL]} is on I2C{scl_controller}. " + f"Both pins must be on the same I2C controller." + ) return config @@ -146,6 +166,23 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") + if CORE.is_rp2040: + if len(full_config) > 2: + raise cv.Invalid( + "The maximum number of I2C interfaces for RP2040/RP2350 is 2" + ) + if len(full_config) > 1: + controllers = [ + _rp2040_i2c_controller(conf[CONF_SDA]) for conf in full_config + ] + if len(set(controllers)) != len(controllers): + raise cv.Invalid( + "Multiple I2C buses are configured to use the same I2C controller. " + "Each bus must use pins on a different controller. " + "The I2C controller is determined by (gpio / 2) % 2: " + "even pin pairs (0-1, 4-5, 8-9, ...) use I2C0, " + "odd pin pairs (2-3, 6-7, 10-11, ...) use I2C1." + ) if CORE.is_esp32 and get_esp32_variant() in ESP32_I2C_CAPABILITIES: variant = get_esp32_variant() max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"] diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 5120eb4c007..47a06abe9ec 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -20,12 +20,14 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) #elif defined(USE_RP2040) - static bool first = true; - if (first) { + // Select Wire instance based on pin assignment, not definition order. + // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 + // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf + // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf + if ((this->sda_pin_ / 2) % 2 == 0) { wire_ = &Wire; - first = false; } else { - wire_ = &Wire1; // NOLINT(cppcoreguidelines-owning-memory) + wire_ = &Wire1; } #endif From a744261934717a3ab52c1e2ec252c0fd46c4ea8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 15:12:22 -1000 Subject: [PATCH 048/279] [mdns] Fix RP2040 mDNS not restarting after WiFi reconnect (#14737) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/mdns/mdns_component.h | 4 +++ esphome/components/mdns/mdns_rp2040.cpp | 32 ++++++++++++++++++---- esphome/components/wifi/__init__.py | 7 ----- esphome/components/wifi/wifi_component.cpp | 14 ---------- esphome/components/wifi/wifi_component.h | 4 --- 5 files changed, 30 insertions(+), 31 deletions(-) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 13c8ccf2884..47cad4bf71b 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -129,6 +129,10 @@ class MDNSComponent final : public Component { #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; +#endif +#ifdef USE_RP2040 + bool was_connected_{false}; + bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 05d991c1fad..c0b22aa84fb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -36,12 +36,32 @@ static void register_rp2040(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_rp2040); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + // RP2040's LEAmDNS library registers a LwipIntf::stateUpCB() callback to restart + // mDNS when the network interface reconnects. However, stateUpCB() is stubbed out + // in arduino-pico's LwipIntfCB.cpp because the original ESP8266 implementation used + // schedule_function() which doesn't exist in arduino-pico, and the callback can't + // safely run directly since netif status callbacks fire from IRQ context + // (PICO_CYW43_ARCH_THREADSAFE_BACKGROUND) while _restart() allocates UDP sockets. + // + // Workaround: defer MDNS.begin() and service registration until the network is + // connected (has an IP), then call notifyAPChange() on subsequent reconnects to + // restart mDNS probing and announcing — all from main loop context so it's + // thread-safe. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { + bool connected = network::is_connected(); + if (connected && !this->was_connected_) { + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + } + this->was_connected_ = connected; + if (this->initialized_) { + MDNS.update(); + } + }); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 480ccd65c54..9f73b1cc6f5 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -563,13 +563,6 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) elif CORE.is_rp2040: cg.add_library("WiFi", None) - # RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart - # mDNS when the network interface reconnects. However, this callback is disabled - # in the arduino-pico framework. As a workaround, we block component setup until - # WiFi is connected via can_proceed(), ensuring mDNS.begin() is called with an - # active connection. This define enables the loop priority sorting infrastructure - # used during the setup blocking phase. - cg.add_define("USE_LOOP_PRIORITY") if CORE.is_esp32: if config[CONF_ENABLE_BTM] or config[CONF_ENABLE_RRM]: diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 60764955cc9..09f883ed617 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2109,20 +2109,6 @@ void WiFiComponent::retry_connect() { } } -#ifdef USE_RP2040 -// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart -// mDNS when the network interface reconnects. However, this callback is disabled -// in the arduino-pico framework. As a workaround, we block component setup until -// WiFi is connected, ensuring mDNS.begin() is called with an active connection. - -bool WiFiComponent::can_proceed() { - if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) { - return true; - } - return this->is_connected_(); -} -#endif - void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool WiFiComponent::is_connected_() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index f340b708c90..883cc1344b4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -437,10 +437,6 @@ class WiFiComponent : public Component { void retry_connect(); -#ifdef USE_RP2040 - bool can_proceed() override; -#endif - void set_reboot_timeout(uint32_t reboot_timeout); bool is_connected() const { return this->connected_; } From 920af91db693e36ad54033db100ecbb3b2915c19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 15:37:46 -1000 Subject: [PATCH 049/279] [rp2040] Fix compiler warnings in crash_handler and mdns (#14739) --- esphome/components/mdns/mdns_rp2040.cpp | 5 +++++ esphome/components/rp2040/crash_handler.cpp | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index c0b22aa84fb..88f707afd37 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -7,7 +7,12 @@ #include "esphome/core/log.h" #include "mdns_component.h" +// Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. +// Save and restore our definition around the include to avoid a redefinition warning. +#pragma push_macro("IRAM_ATTR") +#undef IRAM_ATTR #include +#pragma pop_macro("IRAM_ATTR") namespace esphome::mdns { diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 1f579c2d18e..f9eb42a0f8a 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -57,14 +57,14 @@ static const char *const TAG = "rp2040.crash"; // Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). // The valid field is explicitly cleared in crash_handler_read_and_clear() instead. -static struct { +static struct CrashData { bool valid; uint32_t pc; uint32_t lr; uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} __attribute__((section(".noinit"))) s_crash_data; +} s_crash_data __attribute__((section(".noinit"))); bool crash_handler_has_data() { return s_crash_data.valid; } From 15ec46abfe2f5f93a77cfdac7b8891a726a329f0 Mon Sep 17 00:00:00 2001 From: Michael Kerscher Date: Fri, 13 Mar 2026 06:31:16 +0100 Subject: [PATCH 050/279] [vbus] add DeltaSol CS4 (Citrin Solar 1.3) (#12477) --- esphome/components/vbus/__init__.py | 1 + .../components/vbus/binary_sensor/__init__.py | 41 +++++ .../vbus/binary_sensor/vbus_binary_sensor.cpp | 19 +++ .../vbus/binary_sensor/vbus_binary_sensor.h | 17 +++ esphome/components/vbus/sensor/__init__.py | 141 +++++++++++++++++- .../components/vbus/sensor/vbus_sensor.cpp | 46 ++++++ esphome/components/vbus/sensor/vbus_sensor.h | 35 +++++ tests/components/vbus/common.yaml | 8 + 8 files changed, 307 insertions(+), 1 deletion(-) diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 5790a9cce05..26634964569 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -19,6 +19,7 @@ CONF_DELTASOL_BS_2009 = "deltasol_bs_2009" CONF_DELTASOL_BS2 = "deltasol_bs2" CONF_DELTASOL_C = "deltasol_c" CONF_DELTASOL_CS2 = "deltasol_cs2" +CONF_DELTASOL_CS4 = "deltasol_cs4" CONF_DELTASOL_CS_PLUS = "deltasol_cs_plus" CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 70dda943007..85f1172166f 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -20,6 +20,7 @@ from .. import ( CONF_DELTASOL_BS_PLUS, CONF_DELTASOL_C, CONF_DELTASOL_CS2, + CONF_DELTASOL_CS4, CONF_DELTASOL_CS_PLUS, CONF_VBUS_ID, VBus, @@ -31,6 +32,7 @@ DeltaSol_BS_2009 = vbus_ns.class_("DeltaSolBS2009BSensor", cg.Component) DeltaSol_BS2 = vbus_ns.class_("DeltaSolBS2BSensor", cg.Component) DeltaSol_C = vbus_ns.class_("DeltaSolCBSensor", cg.Component) DeltaSol_CS2 = vbus_ns.class_("DeltaSolCS2BSensor", cg.Component) +DeltaSol_CS4 = vbus_ns.class_("DeltaSolCS4BSensor", cg.Component) DeltaSol_CS_Plus = vbus_ns.class_("DeltaSolCSPlusBSensor", cg.Component) VBusCustom = vbus_ns.class_("VBusCustomBSensor", cg.Component) VBusCustomSub = vbus_ns.class_("VBusCustomSubBSensor", cg.Component) @@ -186,6 +188,28 @@ CONFIG_SCHEMA = cv.typed_schema( ), } ), + CONF_DELTASOL_CS4: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(DeltaSol_CS4), + cv.GenerateID(CONF_VBUS_ID): cv.use_id(VBus), + cv.Optional(CONF_SENSOR1_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SENSOR2_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SENSOR3_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SENSOR4_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), CONF_DELTASOL_CS_PLUS: cv.COMPONENT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(DeltaSol_CS_Plus), @@ -350,6 +374,23 @@ async def to_code(config): sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) cg.add(var.set_s4_error_bsensor(sens)) + elif config[CONF_MODEL] == CONF_DELTASOL_CS4: + cg.add(var.set_command(0x0100)) + cg.add(var.set_source(0x1122)) + cg.add(var.set_dest(0x0010)) + if CONF_SENSOR1_ERROR in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) + cg.add(var.set_s1_error_bsensor(sens)) + if CONF_SENSOR2_ERROR in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) + cg.add(var.set_s2_error_bsensor(sens)) + if CONF_SENSOR3_ERROR in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) + cg.add(var.set_s3_error_bsensor(sens)) + if CONF_SENSOR4_ERROR in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) + cg.add(var.set_s4_error_bsensor(sens)) + elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp index c1d7bc1b18d..e598b1de6b7 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp @@ -110,6 +110,25 @@ void DeltaSolCS2BSensor::handle_message(std::vector &message) { this->s4_error_bsensor_->publish_state(message[18] & 8); } +void DeltaSolCS4BSensor::dump_config() { + ESP_LOGCONFIG(TAG, "Deltasol CS4:"); + LOG_BINARY_SENSOR(" ", "Sensor 1 Error", this->s1_error_bsensor_); + LOG_BINARY_SENSOR(" ", "Sensor 2 Error", this->s2_error_bsensor_); + LOG_BINARY_SENSOR(" ", "Sensor 3 Error", this->s3_error_bsensor_); + LOG_BINARY_SENSOR(" ", "Sensor 4 Error", this->s4_error_bsensor_); +} + +void DeltaSolCS4BSensor::handle_message(std::vector &message) { + if (this->s1_error_bsensor_ != nullptr) + this->s1_error_bsensor_->publish_state(message[20] & 1); + if (this->s2_error_bsensor_ != nullptr) + this->s2_error_bsensor_->publish_state(message[20] & 2); + if (this->s3_error_bsensor_ != nullptr) + this->s3_error_bsensor_->publish_state(message[20] & 4); + if (this->s4_error_bsensor_ != nullptr) + this->s4_error_bsensor_->publish_state(message[20] & 8); +} + void DeltaSolCSPlusBSensor::dump_config() { ESP_LOGCONFIG(TAG, "Deltasol CS Plus:"); LOG_BINARY_SENSOR(" ", "Sensor 1 Error", this->s1_error_bsensor_); diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 2decdde602a..04c9a7b8264 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -94,6 +94,23 @@ class DeltaSolCS2BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; +class DeltaSolCS4BSensor : public VBusListener, public Component { + public: + void dump_config() override; + void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } + void set_s2_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s2_error_bsensor_ = bsensor; } + void set_s3_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s3_error_bsensor_ = bsensor; } + void set_s4_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s4_error_bsensor_ = bsensor; } + + protected: + binary_sensor::BinarySensor *s1_error_bsensor_{nullptr}; + binary_sensor::BinarySensor *s2_error_bsensor_{nullptr}; + binary_sensor::BinarySensor *s3_error_bsensor_{nullptr}; + binary_sensor::BinarySensor *s4_error_bsensor_{nullptr}; + + void handle_message(std::vector &message) override; +}; + class DeltaSolCSPlusBSensor : public VBusListener, public Component { public: void dump_config() override; diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index ff8ef98a1aa..9c3665eb1c7 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -36,6 +36,7 @@ from .. import ( CONF_DELTASOL_BS_PLUS, CONF_DELTASOL_C, CONF_DELTASOL_CS2, + CONF_DELTASOL_CS4, CONF_DELTASOL_CS_PLUS, CONF_VBUS_ID, VBus, @@ -47,6 +48,7 @@ DeltaSol_BS_2009 = vbus_ns.class_("DeltaSolBS2009Sensor", cg.Component) DeltaSol_BS2 = vbus_ns.class_("DeltaSolBS2Sensor", cg.Component) DeltaSol_C = vbus_ns.class_("DeltaSolCSensor", cg.Component) DeltaSol_CS2 = vbus_ns.class_("DeltaSolCS2Sensor", cg.Component) +DeltaSol_CS4 = vbus_ns.class_("DeltaSolCS4Sensor", cg.Component) DeltaSol_CS_Plus = vbus_ns.class_("DeltaSolCSPlusSensor", cg.Component) VBusCustom = vbus_ns.class_("VBusCustomSensor", cg.Component) VBusCustomSub = vbus_ns.class_("VBusCustomSubSensor", cg.Component) @@ -438,6 +440,99 @@ CONFIG_SCHEMA = cv.typed_schema( ), } ), + CONF_DELTASOL_CS4: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(DeltaSol_CS4), + cv.GenerateID(CONF_VBUS_ID): cv.use_id(VBus), + cv.Optional(CONF_TEMPERATURE_1): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_2): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_3): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_4): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_5): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PUMP_SPEED_1): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PUMP_SPEED_2): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_OPERATING_HOURS_1): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + icon=ICON_TIMER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_OPERATING_HOURS_2): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + icon=ICON_TIMER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HEAT_QUANTITY): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_TIME): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + icon=ICON_TIMER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_VERSION): sensor.sensor_schema( + accuracy_decimals=2, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FLOW_RATE): sensor.sensor_schema( + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ), CONF_DELTASOL_CS_PLUS: cv.COMPONENT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(DeltaSol_CS_Plus), @@ -734,7 +829,51 @@ async def to_code(config): sens = await sensor.new_sensor(config[CONF_VERSION]) cg.add(var.set_version_sensor(sens)) - if config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: + elif config[CONF_MODEL] == CONF_DELTASOL_CS4: + cg.add(var.set_command(0x0100)) + cg.add(var.set_source(0x1122)) + cg.add(var.set_dest(0x0010)) + if CONF_TEMPERATURE_1 in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) + cg.add(var.set_temperature1_sensor(sens)) + if CONF_TEMPERATURE_2 in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) + cg.add(var.set_temperature2_sensor(sens)) + if CONF_TEMPERATURE_3 in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) + cg.add(var.set_temperature3_sensor(sens)) + if CONF_TEMPERATURE_4 in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) + cg.add(var.set_temperature4_sensor(sens)) + if CONF_TEMPERATURE_5 in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) + cg.add(var.set_temperature5_sensor(sens)) + if CONF_PUMP_SPEED_1 in config: + sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) + cg.add(var.set_pump_speed1_sensor(sens)) + if CONF_PUMP_SPEED_2 in config: + sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) + cg.add(var.set_pump_speed2_sensor(sens)) + if CONF_OPERATING_HOURS_1 in config: + sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) + cg.add(var.set_operating_hours1_sensor(sens)) + if CONF_OPERATING_HOURS_2 in config: + sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) + cg.add(var.set_operating_hours2_sensor(sens)) + if CONF_HEAT_QUANTITY in config: + sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) + cg.add(var.set_heat_quantity_sensor(sens)) + if CONF_TIME in config: + sens = await sensor.new_sensor(config[CONF_TIME]) + cg.add(var.set_time_sensor(sens)) + if CONF_VERSION in config: + sens = await sensor.new_sensor(config[CONF_VERSION]) + cg.add(var.set_version_sensor(sens)) + if CONF_FLOW_RATE in config: + sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) + cg.add(var.set_flow_rate_sensor(sens)) + + elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 75c9ea1aeed..1cabb49703b 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -168,6 +168,52 @@ void DeltaSolCS2Sensor::handle_message(std::vector &message) { this->version_sensor_->publish_state(get_u16(message, 28) * 0.01f); } +void DeltaSolCS4Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "Deltasol CS4:"); + LOG_SENSOR(" ", "Temperature 1", this->temperature1_sensor_); + LOG_SENSOR(" ", "Temperature 2", this->temperature2_sensor_); + LOG_SENSOR(" ", "Temperature 3", this->temperature3_sensor_); + LOG_SENSOR(" ", "Temperature 4", this->temperature4_sensor_); + LOG_SENSOR(" ", "Temperature 5", this->temperature5_sensor_); + LOG_SENSOR(" ", "Pump Speed 1", this->pump_speed1_sensor_); + LOG_SENSOR(" ", "Pump Speed 2", this->pump_speed2_sensor_); + LOG_SENSOR(" ", "Operating Hours 1", this->operating_hours1_sensor_); + LOG_SENSOR(" ", "Operating Hours 2", this->operating_hours2_sensor_); + LOG_SENSOR(" ", "Heat Quantity", this->heat_quantity_sensor_); + LOG_SENSOR(" ", "System Time", this->time_sensor_); + LOG_SENSOR(" ", "FW Version", this->version_sensor_); + LOG_SENSOR(" ", "Flow Rate", this->flow_rate_sensor_); +} + +void DeltaSolCS4Sensor::handle_message(std::vector &message) { + if (this->temperature1_sensor_ != nullptr) + this->temperature1_sensor_->publish_state(get_i16(message, 0) * 0.1f); + if (this->temperature2_sensor_ != nullptr) + this->temperature2_sensor_->publish_state(get_i16(message, 2) * 0.1f); + if (this->temperature3_sensor_ != nullptr) + this->temperature3_sensor_->publish_state(get_i16(message, 4) * 0.1f); + if (this->temperature4_sensor_ != nullptr) + this->temperature4_sensor_->publish_state(get_i16(message, 6) * 0.1f); + if (this->temperature5_sensor_ != nullptr) + this->temperature5_sensor_->publish_state(get_i16(message, 36) * 0.1f); + if (this->pump_speed1_sensor_ != nullptr) + this->pump_speed1_sensor_->publish_state(message[8]); + if (this->pump_speed2_sensor_ != nullptr) + this->pump_speed2_sensor_->publish_state(message[12]); + if (this->operating_hours1_sensor_ != nullptr) + this->operating_hours1_sensor_->publish_state(get_u16(message, 10)); + if (this->operating_hours2_sensor_ != nullptr) + this->operating_hours2_sensor_->publish_state(get_u16(message, 14)); + if (this->heat_quantity_sensor_ != nullptr) + this->heat_quantity_sensor_->publish_state((get_u16(message, 30) << 16) + get_u16(message, 28)); + if (this->time_sensor_ != nullptr) + this->time_sensor_->publish_state(get_u16(message, 22)); + if (this->version_sensor_ != nullptr) + this->version_sensor_->publish_state(get_u16(message, 32) * 0.01f); + if (this->flow_rate_sensor_ != nullptr) + this->flow_rate_sensor_->publish_state(get_u16(message, 38)); +} + void DeltaSolCSPlusSensor::dump_config() { ESP_LOGCONFIG(TAG, "Deltasol CS Plus:"); LOG_SENSOR(" ", "Temperature 1", this->temperature1_sensor_); diff --git a/esphome/components/vbus/sensor/vbus_sensor.h b/esphome/components/vbus/sensor/vbus_sensor.h index cea2ee1c862..ea248b1db24 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.h +++ b/esphome/components/vbus/sensor/vbus_sensor.h @@ -122,6 +122,41 @@ class DeltaSolCS2Sensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; +class DeltaSolCS4Sensor : public VBusListener, public Component { + public: + void dump_config() override; + void set_temperature1_sensor(sensor::Sensor *sensor) { this->temperature1_sensor_ = sensor; } + void set_temperature2_sensor(sensor::Sensor *sensor) { this->temperature2_sensor_ = sensor; } + void set_temperature3_sensor(sensor::Sensor *sensor) { this->temperature3_sensor_ = sensor; } + void set_temperature4_sensor(sensor::Sensor *sensor) { this->temperature4_sensor_ = sensor; } + void set_temperature5_sensor(sensor::Sensor *sensor) { this->temperature5_sensor_ = sensor; } + void set_pump_speed1_sensor(sensor::Sensor *sensor) { this->pump_speed1_sensor_ = sensor; } + void set_pump_speed2_sensor(sensor::Sensor *sensor) { this->pump_speed2_sensor_ = sensor; } + void set_operating_hours1_sensor(sensor::Sensor *sensor) { this->operating_hours1_sensor_ = sensor; } + void set_operating_hours2_sensor(sensor::Sensor *sensor) { this->operating_hours2_sensor_ = sensor; } + void set_heat_quantity_sensor(sensor::Sensor *sensor) { this->heat_quantity_sensor_ = sensor; } + void set_time_sensor(sensor::Sensor *sensor) { this->time_sensor_ = sensor; } + void set_version_sensor(sensor::Sensor *sensor) { this->version_sensor_ = sensor; } + void set_flow_rate_sensor(sensor::Sensor *sensor) { this->flow_rate_sensor_ = sensor; } + + protected: + sensor::Sensor *temperature1_sensor_{nullptr}; + sensor::Sensor *temperature2_sensor_{nullptr}; + sensor::Sensor *temperature3_sensor_{nullptr}; + sensor::Sensor *temperature4_sensor_{nullptr}; + sensor::Sensor *temperature5_sensor_{nullptr}; + sensor::Sensor *pump_speed1_sensor_{nullptr}; + sensor::Sensor *pump_speed2_sensor_{nullptr}; + sensor::Sensor *operating_hours1_sensor_{nullptr}; + sensor::Sensor *operating_hours2_sensor_{nullptr}; + sensor::Sensor *heat_quantity_sensor_{nullptr}; + sensor::Sensor *time_sensor_{nullptr}; + sensor::Sensor *version_sensor_{nullptr}; + sensor::Sensor *flow_rate_sensor_{nullptr}; + + void handle_message(std::vector &message) override; +}; + class DeltaSolCSPlusSensor : public VBusListener, public Component { public: void dump_config() override; diff --git a/tests/components/vbus/common.yaml b/tests/components/vbus/common.yaml index 5c771be922b..bdd75a2b975 100644 --- a/tests/components/vbus/common.yaml +++ b/tests/components/vbus/common.yaml @@ -19,6 +19,10 @@ binary_sensor: name: BS2 Sensor 3 Error sensor4_error: name: BS2 Sensor 4 Error + - platform: vbus + model: deltasol_cs4 + sensor1_error: + name: "DeltaSol CS4 Sensor 1 Error" - platform: vbus model: custom command: 0x100 @@ -44,6 +48,10 @@ sensor: name: DeltaSol C Heat Quantity time: name: DeltaSol C System Time + - platform: vbus + model: deltasol_cs4 + temperature_1: + name: "DeltaSol CS4 Temperature 1" - platform: vbus model: deltasol_bs2 temperature_1: From 7524590bcfe3fb3e28cdaf4d33b9f9f1a41201f2 Mon Sep 17 00:00:00 2001 From: Thomas SAMTER <7680607+P4uLT@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:17:11 +0100 Subject: [PATCH 051/279] [const] Add CONF_CLIMATE_ID for climate component sub-entities (#14764) --- esphome/components/const/__init__.py | 1 + esphome/components/pid/sensor/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 059bf3f26a9..f6da32569fe 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -3,6 +3,7 @@ CODEOWNERS = ["@esphome/core"] CONF_BYTE_ORDER = "byte_order" +CONF_CLIMATE_ID = "climate_id" BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index 4547f4d7082..d26e88e38ae 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT @@ -21,7 +22,6 @@ PID_CLIMATE_SENSOR_TYPES = { "KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD, } -CONF_CLIMATE_ID = "climate_id" CONFIG_SCHEMA = ( sensor.sensor_schema( PIDClimateSensor, From 326769e43c8c85f6bcb8001301028f7be805acf8 Mon Sep 17 00:00:00 2001 From: Kjell Braden Date: Fri, 13 Mar 2026 14:18:42 +0100 Subject: [PATCH 052/279] [runtime_image] fix BMP parsing (#14762) --- .../components/runtime_image/bmp_decoder.h | 4 + .../fixtures/online_image_bmp.yaml | 27 ++++ tests/integration/test_online_image_bmp.py | 119 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 tests/integration/fixtures/online_image_bmp.yaml create mode 100644 tests/integration/test_online_image_bmp.py diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 73e54f54302..a52a5615849 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -26,6 +26,10 @@ class BmpDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { + if (this->bits_per_pixel_ == 0) { + // header not yet received, so dimensions not yet determined + return false; + } // BMP is finished when we've decoded all pixel data return this->paint_index_ >= static_cast(this->width_ * this->height_); } diff --git a/tests/integration/fixtures/online_image_bmp.yaml b/tests/integration/fixtures/online_image_bmp.yaml new file mode 100644 index 00000000000..e36514e9ae2 --- /dev/null +++ b/tests/integration/fixtures/online_image_bmp.yaml @@ -0,0 +1,27 @@ +esphome: + name: online-image-bmp + +host: + +http_request: + +display: + +online_image: + - url: http://127.0.0.1:HTTP_PORT/foo.bmp + id: myimg + format: BMP + type: RGB + on_download_finished: + logger.log: + format: "download finished. cache hit: %u" + args: [cached] + +api: + actions: + - action: fetch_image + then: + - component.update: myimg + +logger: + level: DEBUG diff --git a/tests/integration/test_online_image_bmp.py b/tests/integration/test_online_image_bmp.py new file mode 100644 index 00000000000..7c32154fdd6 --- /dev/null +++ b/tests/integration/test_online_image_bmp.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# black 8x8 RGB BMP, generated with +# from PIL import Image +# from io import BytesIO +# b = BytesIO() +# img = Image.new("RGB", (8, 8)) +# img.save(b, format="BMP") +# b.getvalue() +BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +LEN_BMP_IMAGE = len(BMP_IMAGE) + + +def handle_http(http_request_future): + async def handler(reader, writer): + try: + async with asyncio.timeout(1.0): + data = await reader.readuntil(b"\r\n") + + # ensure our request matches the expectation + expected_request = b"GET /foo.bmp HTTP/1.1\r\n" + assert data[: len(expected_request)] == expected_request + + # consume rest of request + async with asyncio.timeout(1.0): + data = await reader.readuntil(b"\r\n\r\n") + + http_request_future.set_result(True) + + http_response = [ + b"HTTP/1.1 200 OK", + b"Content-Length: %d" % LEN_BMP_IMAGE, + b"Content-Type: text/plain", + b"Connection: close", + b"", + b"", + ] + writer.write(b"\r\n".join(http_response)) + await writer.drain() + + writer.write(BMP_IMAGE) + + await writer.drain() + except Exception as exc: + if not http_request_future.done(): + http_request_future.set_exception(exc) + raise + finally: + writer.close() + + return handler + + +@pytest.mark.asyncio +async def test_online_image_bmp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Esphome shouldn't block the main loop when a http response is slow""" + loop = asyncio.get_running_loop() + + # Track http request + http_request_future = loop.create_future() + download_finished_future = loop.create_future() + downloaded_bytes_future = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + + if match := re.search(r"Image fully downloaded, (\d+) bytes", line): + downloaded_bytes_future.set_result(int(match.group(1))) + + if "download finished" in line: + download_finished_future.set_result(True) + + server = await asyncio.start_server( + handle_http(http_request_future), "127.0.0.1", 0 + ) + http_server_port = server.sockets[0].getsockname()[1] + + config = yaml_config.replace("HTTP_PORT", str(http_server_port)) + + # Run with log monitoring + async with ( + server, + run_compiled(config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "online-image-bmp" + + # List services to find our test service + _, services = await client.list_entities_services() + + # Find test service + request_service = next((s for s in services if s.name == "fetch_image"), None) + + assert request_service is not None, "fetch_image service not found" + + await client.execute_service(request_service, {}) + + async with asyncio.timeout(0.1): + await http_request_future + + async with asyncio.timeout(0.5): + numbytes = await downloaded_bytes_future + assert numbytes == LEN_BMP_IMAGE + await download_finished_future From 5920fa97e4b671e424f7d53cbbcb1bd1ba8d250c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 03:20:50 -1000 Subject: [PATCH 053/279] [select] Fix -Wmaybe-uninitialized warnings on ESP8266 (#14759) --- esphome/components/select/select_call.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 45fb42c1160..83f5052fc8a 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -41,7 +41,7 @@ SelectCall &SelectCall::with_index(size_t index) { this->operation_ = SELECT_OP_SET; if (index >= this->parent_->size()) { ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index); - this->index_ = {}; // Store nullopt for invalid index + this->index_ = nullopt; // Store nullopt for invalid index } else { this->index_ = index; } @@ -52,7 +52,7 @@ optional SelectCall::calculate_target_index_(const char *name) { const auto &options = this->parent_->traits.get_options(); if (options.empty()) { ESP_LOGW(TAG, "'%s' - Select has no options", name); - return {}; + return nullopt; } if (this->operation_ == SELECT_OP_FIRST) { @@ -67,7 +67,7 @@ optional SelectCall::calculate_target_index_(const char *name) { ESP_LOGD(TAG, "'%s' - Setting", name); if (!this->index_.has_value()) { ESP_LOGW(TAG, "'%s' - No option set", name); - return {}; + return nullopt; } return this->index_; } @@ -96,7 +96,7 @@ optional SelectCall::calculate_target_index_(const char *name) { return active_index + 1; } - return {}; // Can't navigate further without cycling + return nullopt; // Can't navigate further without cycling } void SelectCall::perform() { From 8936be628f4cd4d223aeefa6bed333682073db84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 07:37:30 -1000 Subject: [PATCH 054/279] [api] Increase log Nagle coalescing on all platforms except ESP8266 (#14752) --- esphome/components/api/api_frame_helper.h | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 98de24501ea..5e07ad43a93 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -134,12 +134,16 @@ class APIFrameHelper { // // For log messages: Use Nagle to coalesce multiple small log packets into // fewer larger packets, reducing WiFi overhead. However, we limit batching - // to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained - // devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into - // shared pbufs, but holding data too long waiting for Nagle's timer causes - // buffer exhaustion and dropped messages. + // to avoid excessive LWIP buffer pressure on memory-constrained devices. + // LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but + // holding data too long waiting for Nagle's timer causes buffer exhaustion + // and dropped messages. // - // Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all) + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) + // + // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) + // Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all) // void set_nodelay_for_message(bool is_log_message) { if (!is_log_message) { @@ -150,7 +154,7 @@ class APIFrameHelper { return; } - // Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd) + // Log messages: state transitions -1 -> 1 -> ... -> LOG_NAGLE_COUNT -> -1 (flush) if (this->nodelay_state_ == NODELAY_ON) { this->set_nodelay_raw_(false); this->nodelay_state_ = 1; @@ -255,10 +259,16 @@ class APIFrameHelper { uint8_t tx_buf_tail_{0}; uint8_t tx_buf_count_{0}; // Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled - // (immediate send). Values 1-2 count log messages in the current Nagle batch. + // (immediate send). Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset. + // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. + // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. static constexpr int8_t NODELAY_ON = -1; +#ifdef USE_ESP8266 static constexpr int8_t LOG_NAGLE_COUNT = 2; +#else + static constexpr int8_t LOG_NAGLE_COUNT = 3; +#endif int8_t nodelay_state_{NODELAY_ON}; // Internal helper to set TCP_NODELAY socket option From bd844fcd0aa0cf9857f4a548d58543b7fe020331 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 07:37:44 -1000 Subject: [PATCH 055/279] [template] Fix misleading 'Text value too long to save' warning (#14753) --- .../components/template/text/template_text.h | 32 ++--- .../fixtures/template_text_save.yaml | 23 +++ tests/integration/test_template_text_save.py | 131 ++++++++++++++++++ 3 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 tests/integration/fixtures/template_text_save.yaml create mode 100644 tests/integration/test_template_text_save.py diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 7f176db09ef..229a61d9b8e 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -24,23 +24,23 @@ class TemplateTextSaverBase { template class TextSaver : public TemplateTextSaverBase { public: bool save(const std::string &value) override { - int diff = value.compare(this->prev_); - if (diff != 0) { - // If string is bigger than the allocation, do not save it. - // We don't need to waste ram setting prev_value either. - int size = value.size(); - if (size <= SZ) { - // Make it into a length prefixed thing - unsigned char temp[SZ + 1]; - memcpy(temp + 1, value.c_str(), size); - // SZ should be pre checked at the schema level, it can't go past the char range. - temp[0] = ((unsigned char) size); - this->pref_.save(&temp); - this->prev_.assign(value); - return true; - } + if (value == this->prev_) { + return true; // No change, nothing to save } - return false; + // If string is bigger than the allocation, do not save it. + // We don't need to waste ram setting prev_value either. + int size = value.size(); + if (size > SZ) { + return false; + } + // Make it into a length prefixed thing + unsigned char temp[SZ + 1]; + memcpy(temp + 1, value.c_str(), size); + // SZ should be pre checked at the schema level, it can't go past the char range. + temp[0] = ((unsigned char) size); + this->pref_.save(&temp); + this->prev_.assign(value); + return true; } // Make the preference object. Fill the provided location with the saved data diff --git a/tests/integration/fixtures/template_text_save.yaml b/tests/integration/fixtures/template_text_save.yaml new file mode 100644 index 00000000000..526561732de --- /dev/null +++ b/tests/integration/fixtures/template_text_save.yaml @@ -0,0 +1,23 @@ +esphome: + name: host-template-text-save-test + +host: + +api: + batch_delay: 0ms + +logger: + +preferences: + flash_write_interval: 0s + +text: + - platform: template + name: "Test Text Restore" + id: test_text_restore + optimistic: true + min_length: 0 + max_length: 10 + mode: text + initial_value: "hello" + restore_value: true diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py new file mode 100644 index 00000000000..47c8e3188ab --- /dev/null +++ b/tests/integration/test_template_text_save.py @@ -0,0 +1,131 @@ +"""Integration test for template text restore_value persistence. + +Tests that: +1. A template text with restore_value saves its value to preferences +2. The saved value persists across restarts (binary re-run) +3. Setting the same value again does not produce a spurious "too long" warning +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + + +@pytest.mark.asyncio +async def test_template_text_save( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test template text save/restore persistence and duplicate-save behavior.""" + port, port_socket = reserved_tcp_port + + # Clean up any stale preference file from previous runs + prefs_file = ( + Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs" + ) + if prefs_file.exists(): + prefs_file.unlink() + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + # --- First run: set a value and verify no spurious warnings --- + warning_lines: list[str] = [] + + def capture_warnings(line: str) -> None: + if "too long to save" in line.lower(): + warning_lines.append(line) + + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=capture_warnings + ), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == "host-template-text-save-test" + + entities, _ = await client.list_entities_services() + text_entity = require_entity( + entities, "test_text_restore", TextInfo, "Test Text Restore" + ) + + # Set up state tracking + loop = asyncio.get_running_loop() + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Verify initial value from config + initial = initial_state_helper.initial_states[text_entity.key] + assert isinstance(initial, TextState) + assert initial.state == "hello" + + async def wait_for_state(key: int, timeout: float = 2.0) -> Any: + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Set a new value that fits within max_length + client.text_command(key=text_entity.key, state="world") + state = await wait_for_state(text_entity.key) + assert state.state == "world" + + # Set the same value again - should NOT produce "too long" warning + client.text_command(key=text_entity.key, state="world") + # Give time for the warning to appear (if any) + await asyncio.sleep(0.5) + + # No warnings should have appeared + assert warning_lines == [], ( + f"Unexpected 'too long to save' warning(s): {warning_lines}" + ) + + # --- Second run: verify the value was restored from preferences --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + text_entity = require_entity( + entities, "test_text_restore", TextInfo, "Test Text Restore" + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None)) + await initial_state_helper.wait_for_initial_states() + + # The value should be "world" - restored from preferences + restored = initial_state_helper.initial_states[text_entity.key] + assert isinstance(restored, TextState) + assert restored.state == "world", ( + f"Expected restored value 'world', got '{restored.state}'" + ) + + # Clean up preference file + if prefs_file.exists(): + prefs_file.unlink() From b147830ef954ed5d6c012a041c5b20ea7d88f93b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:24:39 -0400 Subject: [PATCH 056/279] [core] Fix std::isnan conflict with picolibc on ESP-IDF 6.0 (#14768) Co-authored-by: Claude Opus 4.6 --- esphome/core/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index d4a839cb795..e112720f2b4 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -589,7 +589,10 @@ async def _add_looping_components() -> None: async def to_code(config: ConfigType) -> None: cg.add_global(cg.global_ns.namespace("esphome").using) # These can be used by user lambdas, put them to default scope + # picolibc (IDF 6.0+) declares isnan in global scope, conflicting with using std::isnan + cg.add_global(cg.RawStatement("#ifndef __PICOLIBC__")) cg.add_global(cg.RawExpression("using std::isnan")) + cg.add_global(cg.RawStatement("#endif")) cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) From 6700347a4894ca991a7176bac7a6227afd3289e3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:47:12 -0400 Subject: [PATCH 057/279] [wifi] Fix ESP-IDF 6.0 compatibility (#14766) Co-authored-by: Claude Opus 4.6 --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 2 +- .../wifi/wifi_component_esp_idf.cpp | 44 +++++++++++++++---- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 09f883ed617..346276692a1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -6,7 +6,7 @@ #include #ifdef USE_ESP32 -#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 883cc1344b4..aeb32352a97 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -18,7 +18,7 @@ #endif #if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index eca3f192490..2866ec15137 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -17,7 +17,7 @@ #include #include #ifdef USE_WIFI_WPA2_EAP -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include @@ -75,7 +75,11 @@ struct IDFWiFiEvent { #if USE_NETWORK_IPV6 ip_event_got_ip6_t ip_got_ip6; #endif /* USE_NETWORK_IPV6 */ +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + ip_event_assigned_ip_to_client_t ip_assigned_ip_to_client; +#else ip_event_ap_staipassigned_t ip_ap_staipassigned; +#endif } data; }; @@ -116,8 +120,13 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) { memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t)); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + } else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { + memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t)); +#else } else if (event_base == IP_EVENT && event_id == IP_EVENT_AP_STAIPASSIGNED) { memcpy(&event.data.ip_ap_staipassigned, event_data, sizeof(ip_event_ap_staipassigned_t)); +#endif } else { // did not match any event, don't send anything return; @@ -407,7 +416,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (eap_opt.has_value()) { // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. EAPAuth eap = *eap_opt; -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); #else err = esp_wifi_sta_wpa2_ent_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); @@ -419,7 +428,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { int client_cert_len = strlen(eap.client_cert); int client_key_len = strlen(eap.client_key); if (ca_cert_len) { -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); #else err = esp_wifi_sta_wpa2_ent_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); @@ -432,7 +441,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // validation is not required as the config tool has already validated it if (client_cert_len && client_key_len) { // if we have certs, this must be EAP-TLS -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_certificate_and_key((uint8_t *) eap.client_cert, client_cert_len + 1, (uint8_t *) eap.client_key, client_key_len + 1, (uint8_t *) eap.password.c_str(), eap.password.length()); @@ -446,7 +455,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } } else { // in the absence of certs, assume this is username/password based -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); #else err = esp_wifi_sta_wpa2_ent_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); @@ -454,7 +463,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (err != ESP_OK) { ESP_LOGV(TAG, "set_username failed %d", err); } -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); #else err = esp_wifi_sta_wpa2_ent_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); @@ -463,7 +472,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { ESP_LOGV(TAG, "set_password failed %d", err); } // set TTLS Phase 2, defaults to MSCHAPV2 -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_ttls_phase2_method(eap.ttls_phase_2); #else err = esp_wifi_sta_wpa2_ent_set_ttls_phase2_method(eap.ttls_phase_2); @@ -472,7 +481,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { ESP_LOGV(TAG, "set_ttls_phase2_method failed %d", err); } } -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_wifi_sta_enterprise_enable(); #else err = esp_wifi_sta_wpa2_ent_enable(); @@ -628,14 +637,26 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Auth Expired"; case WIFI_REASON_AUTH_LEAVE: return "Auth Leave"; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case WIFI_REASON_DISASSOC_DUE_TO_INACTIVITY: + return "Disassociated Due to Inactivity"; +#else case WIFI_REASON_ASSOC_EXPIRE: return "Association Expired"; +#endif case WIFI_REASON_ASSOC_TOOMANY: return "Too Many Associations"; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case WIFI_REASON_CLASS2_FRAME_FROM_NONAUTH_STA: + return "Class 2 Frame from Non-Authenticated STA"; + case WIFI_REASON_CLASS3_FRAME_FROM_NONASSOC_STA: + return "Class 3 Frame from Non-Associated STA"; +#else case WIFI_REASON_NOT_AUTHED: return "Not Authenticated"; case WIFI_REASON_NOT_ASSOCED: return "Not Associated"; +#endif case WIFI_REASON_ASSOC_LEAVE: return "Association Leave"; case WIFI_REASON_ASSOC_NOT_AUTHED: @@ -688,7 +709,7 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Association comeback time too long"; case WIFI_REASON_SA_QUERY_TIMEOUT: return "SA query timeout"; -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 2) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0) case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: return "No AP found with compatible security"; case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: @@ -917,8 +938,13 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { + const auto &it = data->data.ip_assigned_ip_to_client; +#else } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_AP_STAIPASSIGNED) { const auto &it = data->data.ip_ap_staipassigned; +#endif ESP_LOGV(TAG, "AP client assigned IP " IPSTR, IP2STR(&it.ip)); } } From f41aa8b18c739f98a50f38f6e8cfbca945e04c1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:35:10 +0000 Subject: [PATCH 058/279] Bump ruff from 0.15.5 to 0.15.6 (#14774) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d8f6983959..5e2bfe09cee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.5 + rev: v0.15.6 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 93a20896aa8..acd8383a2fc 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.5 # also change in .pre-commit-config.yaml when updating +ruff==0.15.6 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From a6c08576be6e87df3007d0c7cc11e780377604a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 10:17:40 -1000 Subject: [PATCH 059/279] [sensor] Use FixedRingBuffer in SlidingWindowFilter, add window_size limit (#14736) --- esphome/components/sensor/__init__.py | 34 +++---- esphome/components/sensor/filter.cpp | 34 ++----- esphome/components/sensor/filter.h | 33 +++---- esphome/core/helpers.h | 131 +++++++++++++++++++++++++- 4 files changed, 171 insertions(+), 61 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 4be6ed1b841..64d4dc41778 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -403,9 +403,9 @@ async def filter_out_filter_to_code(config, filter_id): QUANTILE_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), cv.Optional(CONF_QUANTILE, default=0.9): cv.zero_to_one_float, } ), @@ -427,9 +427,9 @@ async def quantile_filter_to_code(config, filter_id): MEDIAN_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -449,9 +449,9 @@ async def median_filter_to_code(config, filter_id): MIN_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -483,9 +483,9 @@ async def min_filter_to_code(config, filter_id): MAX_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -509,9 +509,9 @@ async def max_filter_to_code(config, filter_id): SLIDING_AVERAGE_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=15): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=15): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=15): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=15): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -540,8 +540,8 @@ EXPONENTIAL_AVERAGE_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_ALPHA, default=0.1): cv.positive_float, - cv.Optional(CONF_SEND_EVERY, default=15): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_SEND_EVERY, default=15): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 0fe1effe179..d995ee4111c 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -41,26 +41,14 @@ void Filter::initialize(Sensor *parent, Filter *next) { } // SlidingWindowFilter -SlidingWindowFilter::SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at) - : window_size_(window_size), send_every_(send_every), send_at_(send_every - send_first_at) { - // Allocate ring buffer once at initialization +SlidingWindowFilter::SlidingWindowFilter(uint16_t window_size, uint16_t send_every, uint16_t send_first_at) + : send_every_(send_every), send_at_(send_every - send_first_at) { this->window_.init(window_size); } optional SlidingWindowFilter::new_value(float value) { - // Add value to ring buffer - if (this->window_count_ < this->window_size_) { - // Buffer not yet full - just append - this->window_.push_back(value); - this->window_count_++; - } else { - // Buffer full - overwrite oldest value (ring buffer) - this->window_[this->window_head_] = value; - this->window_head_++; - if (this->window_head_ >= this->window_size_) { - this->window_head_ = 0; - } - } + // Add value to ring buffer (overwrites oldest when full) + this->window_.push_overwrite(value); // Check if we should send a result if (++this->send_at_ >= this->send_every_) { @@ -77,9 +65,8 @@ FixedVector SortedWindowFilter::get_window_values_() { // Copy window without NaN values using FixedVector (no heap allocation) // Returns unsorted values - caller will use std::nth_element for partial sorting as needed FixedVector values; - values.init(this->window_count_); - for (size_t i = 0; i < this->window_count_; i++) { - float v = this->window_[i]; + values.init(this->window_.size()); + for (float v : this->window_) { if (!std::isnan(v)) { values.push_back(v); } @@ -150,8 +137,7 @@ float MaxFilter::compute_result() { return this->find_extremum_window_count_; i++) { - float v = this->window_[i]; + for (float v : this->window_) { if (!std::isnan(v)) { sum += v; valid_count++; @@ -161,7 +147,7 @@ float SlidingWindowMovingAverageFilter::compute_result() { } // ExponentialMovingAverageFilter -ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at) +ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, uint16_t send_every, uint16_t send_first_at) : alpha_(alpha), send_every_(send_every), send_at_(send_every - send_first_at) {} optional ExponentialMovingAverageFilter::new_value(float value) { if (!std::isnan(value)) { @@ -183,7 +169,7 @@ optional ExponentialMovingAverageFilter::new_value(float value) { } return {}; } -void ExponentialMovingAverageFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; } +void ExponentialMovingAverageFilter::set_send_every(uint16_t send_every) { this->send_every_ = send_every; } void ExponentialMovingAverageFilter::set_alpha(float alpha) { this->alpha_ = alpha; } // ThrottleAverageFilter @@ -511,7 +497,7 @@ optional ToNTCTemperatureFilter::new_value(float value) { } // StreamingFilter (base class) -StreamingFilter::StreamingFilter(size_t window_size, size_t send_first_at) +StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at) : window_size_(window_size), send_first_at_(send_first_at) {} optional StreamingFilter::new_value(float value) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 8bfcdb37cfb..6a76bd373e3 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -52,7 +52,7 @@ class Filter { */ class SlidingWindowFilter : public Filter { public: - SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at); + SlidingWindowFilter(uint16_t window_size, uint16_t send_every, uint16_t send_first_at); optional new_value(float value) final; @@ -60,14 +60,10 @@ class SlidingWindowFilter : public Filter { /// Called by new_value() to compute the filtered result from the current window virtual float compute_result() = 0; - /// Access the sliding window values (ring buffer implementation) - /// Use: for (size_t i = 0; i < window_count_; i++) { float val = window_[i]; } - FixedVector window_; - size_t window_head_{0}; ///< Index where next value will be written - size_t window_count_{0}; ///< Number of valid values in window (0 to window_size_) - size_t window_size_; ///< Maximum window size - size_t send_every_; ///< Send result every N values - size_t send_at_; ///< Counter for send_every + /// Sliding window ring buffer - automatically overwrites oldest values when full + FixedRingBuffer window_; + uint16_t send_every_; ///< Send result every N values + uint16_t send_at_; ///< Counter for send_every }; /** Base class for Min/Max filters. @@ -84,8 +80,7 @@ class MinMaxFilter : public SlidingWindowFilter { template float find_extremum_() { float result = NAN; Compare comp; - for (size_t i = 0; i < this->window_count_; i++) { - float v = this->window_[i]; + for (float v : this->window_) { if (!std::isnan(v)) { result = std::isnan(result) ? v : (comp(v, result) ? v : result); } @@ -239,18 +234,18 @@ class SlidingWindowMovingAverageFilter : public SlidingWindowFilter { */ class ExponentialMovingAverageFilter : public Filter { public: - ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at); + ExponentialMovingAverageFilter(float alpha, uint16_t send_every, uint16_t send_first_at); optional new_value(float value) override; - void set_send_every(size_t send_every); + void set_send_every(uint16_t send_every); void set_alpha(float alpha); protected: float accumulator_{NAN}; float alpha_; - size_t send_every_; - size_t send_at_; + uint16_t send_every_; + uint16_t send_at_; bool first_value_{true}; }; @@ -570,7 +565,7 @@ class ToNTCTemperatureFilter : public Filter { */ class StreamingFilter : public Filter { public: - StreamingFilter(size_t window_size, size_t send_first_at); + StreamingFilter(uint16_t window_size, uint16_t send_first_at); optional new_value(float value) final; @@ -584,9 +579,9 @@ class StreamingFilter : public Filter { /// Called by new_value() to reset internal state after sending a result virtual void reset_batch() = 0; - size_t window_size_; - size_t count_{0}; - size_t send_first_at_; + uint16_t window_size_; + uint16_t count_{0}; + uint16_t send_first_at_; bool first_send_{true}; }; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b2517e2d7ac..9828df29cb4 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -301,7 +301,7 @@ template class StaticVector { /// Not thread-safe. All access (push/pop/iteration) must occur from a single /// context, or the caller must provide external synchronization. template class StaticRingBuffer { - using index_type = std::conditional_t<(N <= 255), uint8_t, uint16_t>; + using index_type = std::conditional_t<(N <= std::numeric_limits::max()), uint8_t, uint16_t>; public: class Iterator { @@ -356,6 +356,13 @@ template class StaticRingBuffer { index_type size() const { return this->count_; } bool empty() const { return this->count_ == 0; } + /// Clear all elements (reset to empty) + void clear() { + this->head_ = 0; + this->tail_ = 0; + this->count_ = 0; + } + Iterator begin() { return Iterator(this, 0); } Iterator end() { return Iterator(this, this->count_); } ConstIterator begin() const { return ConstIterator(this, 0); } @@ -368,6 +375,128 @@ template class StaticRingBuffer { index_type count_{0}; }; +/// Fixed-capacity circular buffer - allocates once at runtime, never reallocates. +/// Runtime-sized equivalent of StaticRingBuffer - use when capacity is only known at initialization. +/// Supports FIFO push/pop and iteration over queued elements. +/// Not thread-safe. +template::max()> class FixedRingBuffer { + using index_type = std::conditional_t< + (MAX_CAPACITY <= std::numeric_limits::max()), uint8_t, + std::conditional_t<(MAX_CAPACITY <= std::numeric_limits::max()), uint16_t, uint32_t>>; + + public: + class Iterator { + public: + Iterator(FixedRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + T &operator*() { return buf_->data_[(buf_->head_ + pos_) % buf_->capacity_]; } + Iterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const Iterator &other) const { return pos_ != other.pos_; } + + private: + FixedRingBuffer *buf_; + index_type pos_; + }; + + class ConstIterator { + public: + ConstIterator(const FixedRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + const T &operator*() const { return buf_->data_[(buf_->head_ + pos_) % buf_->capacity_]; } + ConstIterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const ConstIterator &other) const { return pos_ != other.pos_; } + + private: + const FixedRingBuffer *buf_; + index_type pos_; + }; + + FixedRingBuffer() = default; + ~FixedRingBuffer() { + if constexpr (std::is_trivial::value) { + ::operator delete(this->data_); + } else { + delete[] this->data_; + } + } + + // Disable copy + FixedRingBuffer(const FixedRingBuffer &) = delete; + FixedRingBuffer &operator=(const FixedRingBuffer &) = delete; + + /// Allocate capacity - can only be called once + void init(index_type capacity) { + if constexpr (std::is_trivial::value) { + // Raw allocation without initialization (elements are written before read) + // NOLINTNEXTLINE(bugprone-sizeof-expression) + this->data_ = static_cast(::operator new(capacity * sizeof(T))); + } else { + this->data_ = new T[capacity]; + } + this->capacity_ = capacity; + } + + /// Push a value. Returns false if full. + bool push(const T &value) { + if (this->count_ >= this->capacity_) + return false; + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % this->capacity_; + ++this->count_; + return true; + } + + /// Push a value, overwriting the oldest if full. + void push_overwrite(const T &value) { + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % this->capacity_; + if (this->count_ >= this->capacity_) { + // Buffer full - advance head to drop oldest, count stays at capacity + this->head_ = this->tail_; + } else { + ++this->count_; + } + } + + /// Remove the oldest element. + void pop() { + if (this->count_ > 0) { + this->head_ = (this->head_ + 1) % this->capacity_; + --this->count_; + } + } + + T &front() { return this->data_[this->head_]; } + const T &front() const { return this->data_[this->head_]; } + index_type size() const { return this->count_; } + bool empty() const { return this->count_ == 0; } + index_type capacity() const { return this->capacity_; } + bool full() const { return this->count_ == this->capacity_; } + + /// Clear all elements (reset to empty, keep capacity) + void clear() { + this->head_ = 0; + this->tail_ = 0; + this->count_ = 0; + } + + Iterator begin() { return Iterator(this, 0); } + Iterator end() { return Iterator(this, this->count_); } + ConstIterator begin() const { return ConstIterator(this, 0); } + ConstIterator end() const { return ConstIterator(this, this->count_); } + + protected: + T *data_{nullptr}; + index_type head_{0}; + index_type tail_{0}; + index_type count_{0}; + index_type capacity_{0}; +}; + /// Fixed-capacity vector - allocates once at runtime, never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time From 1eed1adfa0314bb53b3716ea59187b49aa40a036 Mon Sep 17 00:00:00 2001 From: Thomas SAMTER <7680607+P4uLT@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:38:45 +0100 Subject: [PATCH 060/279] [pid] Replace std::deque with FixedRingBuffer (#14733) Co-authored-by: J. Nick Koston --- esphome/components/pid/climate.py | 24 ++++++++++------- esphome/components/pid/pid_climate.h | 10 ++++++- esphome/components/pid/pid_controller.cpp | 32 +++++++++++------------ esphome/components/pid/pid_controller.h | 24 ++++++++++------- 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 0e66b676377..18e33b80391 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_KD_MULTIPLIER, default=0.0): cv.float_, cv.Optional( CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1 - ): cv.int_, + ): cv.positive_not_null_int, } ), cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema( @@ -68,8 +68,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STARTING_INTEGRAL_TERM, default=0.0): cv.float_, cv.Optional(CONF_MIN_INTEGRAL, default=-1): cv.float_, cv.Optional(CONF_MAX_INTEGRAL, default=1): cv.float_, - cv.Optional(CONF_DERIVATIVE_AVERAGING_SAMPLES, default=1): cv.int_, - cv.Optional(CONF_OUTPUT_AVERAGING_SAMPLES, default=1): cv.int_, + cv.Optional( + CONF_DERIVATIVE_AVERAGING_SAMPLES, default=1 + ): cv.positive_not_null_int, + cv.Optional( + CONF_OUTPUT_AVERAGING_SAMPLES, default=1 + ): cv.positive_not_null_int, } ), } @@ -102,13 +106,15 @@ async def to_code(config): cg.add(var.set_starting_integral_term(params[CONF_STARTING_INTEGRAL_TERM])) cg.add(var.set_derivative_samples(params[CONF_DERIVATIVE_AVERAGING_SAMPLES])) - cg.add(var.set_output_samples(params[CONF_OUTPUT_AVERAGING_SAMPLES])) + output_samples = params[CONF_OUTPUT_AVERAGING_SAMPLES] + cg.add(var.set_output_samples(output_samples)) if CONF_MIN_INTEGRAL in params: cg.add(var.set_min_integral(params[CONF_MIN_INTEGRAL])) if CONF_MAX_INTEGRAL in params: cg.add(var.set_max_integral(params[CONF_MAX_INTEGRAL])) + deadband_output_samples = 1 if CONF_DEADBAND_PARAMETERS in config: params = config[CONF_DEADBAND_PARAMETERS] cg.add(var.set_threshold_low(params[CONF_THRESHOLD_LOW])) @@ -116,11 +122,11 @@ async def to_code(config): cg.add(var.set_kp_multiplier(params[CONF_KP_MULTIPLIER])) cg.add(var.set_ki_multiplier(params[CONF_KI_MULTIPLIER])) cg.add(var.set_kd_multiplier(params[CONF_KD_MULTIPLIER])) - cg.add( - var.set_deadband_output_samples( - params[CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES] - ) - ) + deadband_output_samples = params[CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES] + cg.add(var.set_deadband_output_samples(deadband_output_samples)) + + # Single shared output buffer sized to max of both modes + cg.add(var.init_output_buffer(max(output_samples, deadband_output_samples))) cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE])) diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index dc0a92efed5..3708c29ff1b 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -28,7 +28,11 @@ class PIDClimate : public climate::Climate, public Component { void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; } void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; } void set_output_samples(int in) { controller_.output_samples_ = in; } - void set_derivative_samples(int in) { controller_.derivative_samples_ = in; } + void set_derivative_samples(int in) { + controller_.derivative_samples_ = in; + if (in > 1) // No allocation needed when samples=1 (ring_buffer_average_ short-circuits) + controller_.derivative_window_.init(in); + } void set_threshold_low(float in) { controller_.threshold_low_ = in; } void set_threshold_high(float in) { controller_.threshold_high_ = in; } @@ -38,6 +42,10 @@ class PIDClimate : public climate::Climate, public Component { void set_starting_integral_term(float in) { controller_.set_starting_integral_term(in); } void set_deadband_output_samples(int in) { controller_.deadband_output_samples_ = in; } + void init_output_buffer(int size) { + if (size > 1) // No allocation needed when samples=1 (ring_buffer_average_ short-circuits) + controller_.output_window_.init(size); + } float get_output_value() const { return output_value_; } float get_error_value() const { return controller_.error_; } diff --git a/esphome/components/pid/pid_controller.cpp b/esphome/components/pid/pid_controller.cpp index 5d7aecdb052..cab15331cde 100644 --- a/esphome/components/pid/pid_controller.cpp +++ b/esphome/components/pid/pid_controller.cpp @@ -21,9 +21,9 @@ float PIDController::update(float setpoint, float process_value) { // u(t) := p(t) + i(t) + d(t) float output = proportional_term_ + integral_term_ + derivative_term_; - // smooth/sample the output + // smooth/sample the output using shared buffer with mode-appropriate sample count int samples = in_deadband() ? deadband_output_samples_ : output_samples_; - return weighted_average_(output_list_, output, samples); + return ring_buffer_average_(output_window_, output, samples); } bool PIDController::in_deadband() { @@ -83,7 +83,7 @@ void PIDController::calculate_derivative_term_(float setpoint) { previous_setpoint_ = setpoint; // smooth the derivative samples - derivative = weighted_average_(derivative_list_, derivative, derivative_samples_); + derivative = ring_buffer_average_(derivative_window_, derivative, derivative_samples_); derivative_term_ = kd_ * derivative; @@ -93,25 +93,23 @@ void PIDController::calculate_derivative_term_(float setpoint) { } } -float PIDController::weighted_average_(std::deque &list, float new_value, int samples) { - // if only 1 sample needed, clear the list and return - if (samples == 1) { - list.clear(); +float PIDController::ring_buffer_average_(FixedRingBuffer &buf, float new_value, int max_samples) { + // if only 1 sample needed (or invalid), clear the buffer and return + if (max_samples <= 1) { + buf.clear(); return new_value; } - // add the new item to the list - list.push_front(new_value); + // Trim oldest entries to make room (handles mode-switching where buffer + // may have more entries than the current mode needs) + while (buf.size() >= static_cast(max_samples)) + buf.pop(); + buf.push(new_value); - // keep only 'samples' readings, by popping off the back of the list - while (samples > 0 && list.size() > static_cast(samples)) - list.pop_back(); - - // calculate and return the average of all values in the list float sum = 0; - for (auto &elem : list) - sum += elem; - return sum / list.size(); + for (auto val : buf) + sum += val; + return sum / buf.size(); } float PIDController::calculate_relative_time_() { diff --git a/esphome/components/pid/pid_controller.h b/esphome/components/pid/pid_controller.h index e2a7030b571..6848a23965a 100644 --- a/esphome/components/pid/pid_controller.h +++ b/esphome/components/pid/pid_controller.h @@ -1,6 +1,7 @@ #pragma once + #include "esphome/core/hal.h" -#include +#include "esphome/core/helpers.h" #include namespace esphome { @@ -24,10 +25,10 @@ struct PIDController { /// Differential gain K_d. float kd_ = 0; - // smooth the derivative value using a weighted average over X samples - int derivative_samples_ = 8; + // smooth the derivative value using an average over X samples + int derivative_samples_ = 1; - /// smooth the output value using a weighted average over X values + /// smooth the output value using an average over X values int output_samples_ = 1; float threshold_low_ = 0.0f; @@ -50,7 +51,10 @@ struct PIDController { void calculate_proportional_term_(); void calculate_integral_term_(); void calculate_derivative_term_(float setpoint); - float weighted_average_(std::deque &list, float new_value, int samples); + + /// Ring buffer smoothing using FixedRingBuffer (single allocation at setup) + float ring_buffer_average_(FixedRingBuffer &buf, float new_value, int max_samples); + float calculate_relative_time_(); /// Error from previous update used for derivative term @@ -60,12 +64,12 @@ struct PIDController { float accumulated_integral_ = 0; uint32_t last_time_ = 0; - // this is a list of derivative values for smoothing. - std::deque derivative_list_; + // Ring buffer for derivative smoothing + FixedRingBuffer derivative_window_; - // this is a list of output values for smoothing. - std::deque output_list_; + // Ring buffer for output smoothing (shared between normal and deadband modes) + FixedRingBuffer output_window_; -}; // Struct PID Controller +}; // Struct PIDController } // namespace pid } // namespace esphome From cdb445f69da73e51d72a45bb4488aa760be1427b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:00:28 -0400 Subject: [PATCH 061/279] [mipi_dsi] Fix ESP-IDF 6.0 compatibility for LCD color format (#14785) Co-authored-by: Claude Opus 4.6 --- esphome/components/mipi_dsi/mipi_dsi.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 815b9d75a1d..7103e0868dc 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -54,6 +54,17 @@ void MIPI_DSI::setup() { this->smark_failed(LOG_STR("new_panel_io_dbi failed"), err); return; } + // clang-format off +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + auto color_format = LCD_COLOR_FMT_RGB565; + if (this->color_depth_ == display::COLOR_BITNESS_888) { + color_format = LCD_COLOR_FMT_RGB888; + } + esp_lcd_dpi_panel_config_t dpi_config = {.virtual_channel = 0, + .dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT, + .dpi_clock_freq_mhz = this->pclk_frequency_, + .in_color_format = color_format, +#else auto pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565; if (this->color_depth_ == display::COLOR_BITNESS_888) { pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB888; @@ -62,6 +73,7 @@ void MIPI_DSI::setup() { .dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT, .dpi_clock_freq_mhz = this->pclk_frequency_, .pixel_format = pixel_format, +#endif .num_fbs = 1, // number of frame buffers to allocate .video_timing = { @@ -77,6 +89,7 @@ void MIPI_DSI::setup() { .flags = { .use_dma2d = true, }}; + // clang-format on err = esp_lcd_new_panel_dpi(this->bus_handle_, &dpi_config, &this->handle_); if (err != ESP_OK) { this->smark_failed(LOG_STR("esp_lcd_new_panel_dpi failed"), err); From ab3b677113ff9bf6a00a2159b6f3d6817a17b916 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:11:18 -0400 Subject: [PATCH 062/279] [adc] Fix ESP-IDF 6.0 compatibility for ADC_ATTEN_DB_12 (#14784) Co-authored-by: Claude Opus 4.6 --- esphome/components/adc/adc_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 91cf4eaafcc..cf48ccd9c3d 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -22,7 +22,8 @@ namespace adc { #ifdef USE_ESP32 // clang-format off -#if (ESP_IDF_VERSION_MAJOR == 5 && \ +#if ESP_IDF_VERSION_MAJOR >= 6 || \ + (ESP_IDF_VERSION_MAJOR == 5 && \ ((ESP_IDF_VERSION_MINOR == 0 && ESP_IDF_VERSION_PATCH >= 5) || \ (ESP_IDF_VERSION_MINOR == 1 && ESP_IDF_VERSION_PATCH >= 3) || \ (ESP_IDF_VERSION_MINOR >= 2)) \ From 22062d79a2b7d82bf9f62f9dc92f37d8f8e91686 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 13:20:17 -1000 Subject: [PATCH 063/279] [analyze-memory] Add function call frequency analysis (#14779) --- esphome/analyze_memory/__init__.py | 56 ++++++++++++++- esphome/analyze_memory/cli.py | 109 +++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index bf1bcbfa050..7954c228224 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -1,6 +1,6 @@ """Memory usage analyzer for ESPHome compiled binaries.""" -from collections import defaultdict +from collections import Counter, defaultdict from dataclasses import dataclass, field import logging from pathlib import Path @@ -40,6 +40,15 @@ _READELF_SECTION_PATTERN = re.compile( r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)" ) +# Regex for extracting call targets from objdump disassembly +# Matches direct call instructions across architectures: +# Xtensa: call0/call4/call8/call12/callx0/callx4/callx8/callx12 +# ARM: bl/blx +# Captures the mangled symbol name inside angle brackets. +_CALL_TARGET_PATTERN = re.compile( + r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>" +) + # Component category prefixes _COMPONENT_PREFIX_ESPHOME = "[esphome]" _COMPONENT_PREFIX_EXTERNAL = "[external]" @@ -197,6 +206,8 @@ class MemoryAnalyzer: self._lib_hash_to_name: dict[str, str] = {} # Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns" self._heuristic_to_lib: dict[str, str] = {} + # Function call counts: mangled_name -> call_count + self._function_call_counts: Counter[str] = Counter() def analyze(self) -> dict[str, ComponentMemory]: """Analyze the ELF file and return component memory usage.""" @@ -206,6 +217,7 @@ class MemoryAnalyzer: self._categorize_symbols() self._analyze_cswtch_symbols() self._analyze_sdk_libraries() + self._analyze_function_calls() return dict(self.components) def _parse_sections(self) -> None: @@ -384,8 +396,9 @@ class MemoryAnalyzer: return _LOGGER.info("Demangling %d symbols", len(symbols)) - self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path) - _LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache)) + demangled = batch_demangle(symbols, objdump_path=self.objdump_path) + self._demangle_cache.update(demangled) + _LOGGER.info("Successfully demangled %d symbols", len(demangled)) def _demangle_symbol(self, symbol: str) -> str: """Get demangled C++ symbol name from cache.""" @@ -1011,6 +1024,43 @@ class MemoryAnalyzer: total_size, ) + def _analyze_function_calls(self) -> None: + """Count function call sites by parsing disassembly output. + + Parses direct call instructions (call0/call8/bl/blx) from objdump -d + to count how many times each function is called. This helps identify + inlining candidates — frequently called small functions benefit most + from inlining. + """ + result = run_tool( + [self.objdump_path, "-d", str(self.elf_path)], + timeout=60, + ) + if result is None or result.returncode != 0: + _LOGGER.debug("Failed to disassemble ELF for function call analysis") + return + + self._function_call_counts = Counter( + match.group(1) + for line in result.stdout.splitlines() + if (match := _CALL_TARGET_PATTERN.search(line)) + ) + + # Demangle any call targets not already in the cache + missing = [ + name + for name in self._function_call_counts + if name not in self._demangle_cache + ] + if missing: + self._batch_demangle_symbols(missing) + + _LOGGER.debug( + "Function call analysis: %d unique targets, %d total calls", + len(self._function_call_counts), + sum(self._function_call_counts.values()), + ) + def get_unattributed_ram(self) -> tuple[int, int, int]: """Get unattributed RAM sizes (SDK/framework overhead). diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index dbc19c6b89d..acaf5f45621 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -231,6 +231,110 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f" {size:>6,} B {sym_name}") lines.append("") + # Number of top called functions to show + TOP_CALLS_LIMIT: int = 50 + # Number of inlining candidates to show + INLINE_CANDIDATES_LIMIT: int = 25 + # Maximum function size in bytes to consider for inlining + INLINE_SIZE_THRESHOLD: int = 16 + + def _build_symbol_sizes(self) -> dict[str, int]: + """Build a size lookup from all component symbols: mangled_name -> size.""" + return { + symbol: size + for symbols in self._component_symbols.values() + for symbol, _, size, _ in symbols + } + + def _format_call_row( + self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int] + ) -> str: + """Format a single row for call frequency tables.""" + demangled = self._demangle_cache.get(mangled, mangled) + if len(demangled) > 80: + demangled = f"{demangled[:77]}..." + size = symbol_sizes.get(mangled) + size_str = f"{size:>5,} B" if size is not None else " ?" + return f"{index:>3} {count:>5} {size_str} {demangled}" + + def _add_call_table_header(self, lines: list[str]) -> None: + """Add the header row for call frequency tables.""" + lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function") + lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}") + + def _add_function_call_analysis(self, lines: list[str]) -> None: + """Add function call frequency analysis section. + + Shows the most frequently called functions by call site count. + """ + self._add_section_header(lines, "Top Called Functions") + + symbol_sizes = self._build_symbol_sizes() + + # Sort by call count descending + sorted_calls = sorted( + self._function_call_counts.items(), key=lambda x: x[1], reverse=True + ) + + self._add_call_table_header(lines) + + for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]): + lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes)) + + total_calls = sum(self._function_call_counts.values()) + lines.append("") + lines.append( + f"Total: {len(self._function_call_counts)} unique targets, " + f"{total_calls:,} call sites" + ) + lines.append("") + + def _add_inline_candidates(self, lines: list[str]) -> None: + """Add inlining candidates section. + + Shows frequently called functions that are small enough to benefit + from inlining (< 16 bytes). These are the best candidates for + reducing call overhead. + """ + self._add_section_header( + lines, + f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)", + ) + + symbol_sizes = self._build_symbol_sizes() + + # Filter to small functions with known size, sort by call count + candidates = sorted( + ( + (mangled, count) + for mangled, count in self._function_call_counts.items() + if mangled in symbol_sizes + and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD + ), + key=lambda x: x[1], + reverse=True, + ) + + if not candidates: + lines.append("No candidates found.") + lines.append("") + return + + self._add_call_table_header(lines) + + for i, (mangled, count) in enumerate( + candidates[: self.INLINE_CANDIDATES_LIMIT] + ): + lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes)) + + lines.append("") + lines.append( + f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} " + f"of {len(candidates)} functions under " + f"{self.INLINE_SIZE_THRESHOLD} B" + ) + lines.append("") + def generate_report(self, detailed: bool = False) -> str: """Generate a formatted memory report.""" components = sorted( @@ -533,6 +637,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): if self._cswtch_symbols: self._add_cswtch_analysis(lines) + # Function call frequency analysis + if self._function_call_counts: + self._add_function_call_analysis(lines) + self._add_inline_candidates(lines) + lines.append( "Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included." ) From 56f7b3e61b0bb6defd652092d9f4c13e4dabd593 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 13:20:35 -1000 Subject: [PATCH 064/279] [ci] Only run integration tests for changed components (#14776) --- .github/workflows/ci.yml | 17 ++- script/determine-jobs.py | 104 ++++++++----- script/helpers.py | 132 +++++++++++++++-- tests/script/test_determine_jobs.py | 217 ++++++++++++++++++++++------ tests/script/test_helpers.py | 30 +++- 5 files changed, 400 insertions(+), 100 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 461e676c4e6..fedfebf393f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,8 @@ jobs: - common outputs: integration-tests: ${{ steps.determine.outputs.integration-tests }} + integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }} + integration-test-files: ${{ steps.determine.outputs.integration-test-files }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} @@ -210,6 +212,8 @@ jobs: # Extract individual fields echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT + echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT + echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT @@ -261,9 +265,20 @@ jobs: - name: Register matcher run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests + env: + INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }} + INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }} run: | . venv/bin/activate - pytest -vv --no-cov --tb=native -n auto tests/integration/ + if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then + echo "Running all integration tests" + pytest -vv --no-cov --tb=native -n auto tests/integration/ + else + # Parse JSON array into bash array to avoid shell expansion issues + mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]') + echo "Running ${#test_files[@]} specific integration tests" + pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" + fi cpp-unit-tests: name: Run C++ unit tests diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 318ac04a7d0..6808a3cf6c9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -6,6 +6,8 @@ what files have changed. It outputs JSON with the following structure: { "integration_tests": true/false, + "integration_tests_run_all": true/false, + "integration_test_files": ["tests/integration/test_foo.py", ...], "clang_tidy": true/false, "clang_format": true/false, "python_linters": true/false, @@ -56,13 +58,13 @@ from helpers import ( core_changed, filter_component_and_test_cpp_files, filter_component_and_test_files, - get_all_dependencies, get_changed_components, get_component_from_path, get_component_test_files, - get_components_from_integration_fixtures, get_components_with_dependencies, get_cpp_changed_components, + get_fixture_to_test_files, + get_integration_test_files_for_components, get_target_branch, git_ls_files, parse_test_filename, @@ -143,65 +145,88 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ ] -def should_run_integration_tests(branch: str | None = None) -> bool: - """Determine if integration tests should run based on changed files. +def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[str]]: + """Determine which integration tests should run based on changed files. - This function is used by the CI workflow to intelligently skip integration tests when they're - not needed, saving significant CI time and resources. + This function is used by the CI workflow to intelligently skip or filter + integration tests, saving significant CI time and resources. - Integration tests will run when ANY of the following conditions are met: + Returns (run_all=True, []) when ANY of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - Any .cpp, .h, .tcc files in the core directory - These files contain fundamental functionality used throughout ESPHome - - Examples: esphome/core/component.cpp, esphome/core/application.h 2. Core Python files changed (esphome/core/*.py) - Only .py files in the esphome/core/ directory - These are core Python files that affect the entire system - - Examples: esphome/core/config.py, esphome/core/__init__.py - - NOT included: esphome/*.py, esphome/dashboard/*.py, esphome/components/*/*.py - 3. Integration test files changed - - Any file in tests/integration/ directory - - This includes test files themselves and fixture YAML files - - Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml + 3. Integration test infrastructure files changed + - conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc. - 4. Components used by integration tests (or their dependencies) changed - - The function parses all YAML files in tests/integration/fixtures/ - - Extracts which components are used in integration tests - - Recursively finds all dependencies of those components - - If any of these components have changes, tests must run - - Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket', - then changes to sensor/, api/, or socket/ components trigger tests + Returns (run_all=False, [test_files...]) when: + + 4. Specific integration test files changed + - Only those specific test files are returned + + 5. Components used by integration tests (or their dependencies) changed + - Only test files whose fixtures use the changed components are returned Args: branch: Branch to compare against. If None, uses default. Returns: - True if integration tests should run, False otherwise. + Tuple of (run_all, test_files) where: + - run_all: True if all integration tests should run + - test_files: List of specific test file paths to run (empty if run_all + is True, or if no tests need to run) """ files = changed_files(branch) if core_changed(files): - # If any core files changed, run integration tests - return True + # If any core files changed, run all integration tests + return (True, []) - # Check if any integration test files changed - if any("tests/integration" in file for file in files): - return True + # If infrastructure Python files changed (conftest, utils, etc.), run all tests + # Excludes test files (test_*.py), fixtures, and non-Python files (README.md) + if any( + f.startswith("tests/integration/") + and f.endswith(".py") + and not f.startswith("tests/integration/test_") + and "/fixtures/" not in f + for f in files + ): + return (True, []) - # Get all components used in integration tests and their dependencies - fixture_components = get_components_from_integration_fixtures() - all_required_components = get_all_dependencies(fixture_components) + # Collect specific test files that need to run + test_files: set[str] = set() + fixture_to_test_files = get_fixture_to_test_files() - # Check if any required components changed - for file in files: - component = get_component_from_path(file) - if component and component in all_required_components: - return True + for f in files: + if f.startswith("tests/integration/test_") and f.endswith(".py"): + test_files.add(f) + elif f.startswith("tests/integration/fixtures/"): + if f.endswith(".yaml"): + # Fixture YAML changed - add corresponding test file(s) + test_files.update(fixture_to_test_files.get(Path(f).stem, ())) + else: + # Non-YAML fixture file changed (e.g., external_components/) + # Run all tests since we can't determine which tests are affected + return (True, []) - return False + # Find test files whose fixtures use any of the changed components + changed_component_set = { + component for file in files if (component := get_component_from_path(file)) + } + if changed_component_set: + test_files.update( + get_integration_test_files_for_components(changed_component_set) + ) + + if test_files: + return (False, sorted(test_files)) + + return (False, []) @cache @@ -682,7 +707,10 @@ def main() -> None: args = parser.parse_args() # Determine what should run - run_integration = should_run_integration_tests(args.branch) + integration_run_all, integration_test_files = determine_integration_tests( + args.branch + ) + run_integration = integration_run_all or bool(integration_test_files) run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) @@ -810,6 +838,8 @@ def main() -> None: output: dict[str, Any] = { "integration_tests": run_integration, + "integration_tests_run_all": integration_run_all, + "integration_test_files": integration_test_files, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, diff --git a/script/helpers.py b/script/helpers.py index 6ee286a657f..9665af70ec7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -700,37 +700,141 @@ def get_all_dependencies( return all_components +def _extract_components_from_yaml(config: dict) -> set[str]: + """Extract component names from a parsed YAML config. + + Args: + config: Parsed YAML configuration dictionary + + Returns: + Set of component names found in the config + """ + components: set[str] = set() + + # Add all top-level component keys (skip YAML anchor keys starting with '.') + components.update(k for k in config if isinstance(k, str) and not k.startswith(".")) + + # Add platform values from list entries (e.g., sensor -> platform: template adds "template") + for value in config.values(): + if isinstance(value, list): + components.update( + item["platform"] + for item in value + if isinstance(item, dict) and "platform" in item + ) + + return components + + def get_components_from_integration_fixtures() -> set[str]: """Extract all components used in integration test fixtures. Returns: Set of component names used in integration test fixtures """ + return { + comp + for components in get_components_per_integration_fixture().values() + for comp in components + } + + +@cache +def get_components_per_integration_fixture() -> dict[str, set[str]]: + """Extract components used in each integration test fixture. + + Returns: + Dictionary mapping fixture name (stem) to set of component names + """ from esphome import yaml_util - components: set[str] = set() + result: dict[str, set[str]] = {} fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" for yaml_file in fixtures_dir.glob("*.yaml"): - config: dict[str, any] | None = yaml_util.load_yaml(yaml_file) + config: dict[str, Any] | None = yaml_util.load_yaml(yaml_file) if not config: continue - # Add all top-level component keys (skip YAML anchor keys starting with '.') - components.update( - k for k in config if isinstance(k, str) and not k.startswith(".") - ) + result[yaml_file.stem] = _extract_components_from_yaml(config) - # Add platform components (e.g., output.template) - for value in config.values(): - if not isinstance(value, list): - continue + return result - for item in value: - if isinstance(item, dict) and "platform" in item: - components.add(item["platform"]) - return components +_TEST_FUNC_RE = re.compile(r"async def (test_\w+)") + + +@cache +def get_fixture_to_test_files() -> dict[str, frozenset[str]]: + """Map integration test fixture names to the test files that use them. + + Returns: + Dictionary mapping fixture name to frozenset of test file paths + (relative to repo root) + """ + integration_dir = Path(__file__).parent.parent / "tests" / "integration" + result: dict[str, set[str]] = {} + + for test_file in integration_dir.glob("test_*.py"): + content = test_file.read_text(encoding="utf-8") + rel_path = test_file.relative_to(Path(__file__).parent.parent).as_posix() + for func in _TEST_FUNC_RE.findall(content): + base_name = func.replace("test_", "").partition("[")[0] + result.setdefault(base_name, set()).add(rel_path) + + return {k: frozenset(v) for k, v in result.items()} + + +@cache +def _get_component_to_integration_test_files() -> dict[str, frozenset[str]]: + """Build index mapping each component to the test files that depend on it. + + Resolves full dependency trees once per fixture, then inverts the mapping + so lookups are O(1) per component. + + Returns: + Dictionary mapping component name to frozenset of test file paths + """ + fixture_components = get_components_per_integration_fixture() + fixture_to_test_files = get_fixture_to_test_files() + + result: dict[str, set[str]] = {} + for fixture_name, components in fixture_components.items(): + test_files = fixture_to_test_files.get(fixture_name) + if not test_files: + continue + # Get full dependency tree for this fixture's components + all_deps = get_all_dependencies(components) + for dep in all_deps: + result.setdefault(dep, set()).update(test_files) + + return {k: frozenset(v) for k, v in result.items()} + + +def get_integration_test_files_for_components( + changed_components: set[str], +) -> list[str]: + """Get integration test file paths that use any of the given components. + + Uses a precomputed component → test files index for O(C) lookup + where C is the number of changed components. + + Args: + changed_components: Set of component names that have changed + + Returns: + Sorted list of test file paths relative to repo root + (e.g., ["tests/integration/test_api.py", ...]) + """ + component_to_tests = _get_component_to_integration_test_files() + + return sorted( + { + test_file + for component in changed_components + for test_file in component_to_tests.get(component, ()) + } + ) def filter_component_and_test_files(file_path: str) -> bool: diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 61ef8985df9..5c81ad374b1 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -29,9 +29,9 @@ spec.loader.exec_module(determine_jobs) @pytest.fixture -def mock_should_run_integration_tests() -> Generator[Mock, None, None]: - """Mock should_run_integration_tests from helpers.""" - with patch.object(determine_jobs, "should_run_integration_tests") as mock: +def mock_determine_integration_tests() -> Generator[Mock, None, None]: + """Mock determine_integration_tests.""" + with patch.object(determine_jobs, "determine_integration_tests") as mock: yield mock @@ -87,7 +87,7 @@ def clear_determine_jobs_caches() -> None: def test_main_all_tests_should_run( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -100,7 +100,7 @@ def test_main_all_tests_should_run( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = True + mock_determine_integration_tests.return_value = (True, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True @@ -152,6 +152,8 @@ def test_main_all_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is True + assert output["integration_tests_run_all"] is True + assert output["integration_test_files"] == [] assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True @@ -183,7 +185,7 @@ def test_main_all_tests_should_run( def test_main_no_tests_should_run( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -196,7 +198,7 @@ def test_main_no_tests_should_run( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -233,6 +235,8 @@ def test_main_no_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is False + assert output["integration_tests_run_all"] is False + assert output["integration_test_files"] == [] assert output["clang_tidy"] is False assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False @@ -253,7 +257,7 @@ def test_main_no_tests_should_run( def test_main_with_branch_argument( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -266,7 +270,7 @@ def test_main_with_branch_argument( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True @@ -302,7 +306,7 @@ def test_main_with_branch_argument( determine_jobs.main() # Check that functions were called with branch - mock_should_run_integration_tests.assert_called_once_with("main") + mock_determine_integration_tests.assert_called_once_with("main") mock_should_run_clang_tidy.assert_called_once_with("main") mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main") @@ -312,6 +316,8 @@ def test_main_with_branch_argument( output = json.loads(captured.out) assert output["integration_tests"] is False + assert output["integration_tests_run_all"] is False + assert output["integration_test_files"] == [] assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False @@ -334,30 +340,33 @@ def test_main_with_branch_argument( assert output["cpp_unit_tests_components"] == ["mqtt"] -def test_should_run_integration_tests( +def test_determine_integration_tests( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test should_run_integration_tests function.""" - # Core C++ files trigger tests + """Test determine_integration_tests function.""" + # Core C++ files trigger run_all with patch.object( determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"] ): - result = determine_jobs.should_run_integration_tests() - assert result is True + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] - # Core Python files trigger tests + # Core Python files trigger run_all with patch.object( determine_jobs, "changed_files", return_value=["esphome/core/config.py"] ): - result = determine_jobs.should_run_integration_tests() - assert result is True + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] # Python files directly in esphome/ do NOT trigger tests with patch.object( determine_jobs, "changed_files", return_value=["esphome/config.py"] ): - result = determine_jobs.should_run_integration_tests() - assert result is False + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [] # Python files in subdirectories (not core) do NOT trigger tests with patch.object( @@ -365,35 +374,151 @@ def test_should_run_integration_tests( "changed_files", return_value=["esphome/dashboard/web_server.py"], ): - result = determine_jobs.should_run_integration_tests() - assert result is False + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [] -def test_should_run_integration_tests_with_branch() -> None: - """Test should_run_integration_tests with branch argument.""" +def test_determine_integration_tests_with_branch() -> None: + """Test determine_integration_tests with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - determine_jobs.should_run_integration_tests("release") + run_all, test_files = determine_jobs.determine_integration_tests("release") mock_changed.assert_called_once_with("release") + assert run_all is False + assert test_files == [] -def test_should_run_integration_tests_component_dependency() -> None: - """Test that integration tests run when components used in fixtures change.""" +def test_determine_integration_tests_component_dependency() -> None: + """Test that integration tests return specific test files when components used in fixtures change.""" with ( patch.object( determine_jobs, "changed_files", return_value=["esphome/components/api/api.cpp"], ), + patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map, patch.object( - determine_jobs, "get_components_from_integration_fixtures" - ) as mock_fixtures, + determine_jobs, "get_integration_test_files_for_components" + ) as mock_test_files, ): - mock_fixtures.return_value = {"api", "sensor"} - with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: - mock_deps.return_value = {"api", "sensor", "network"} - result = determine_jobs.should_run_integration_tests() - assert result is True + mock_fixture_map.return_value = {} + mock_test_files.return_value = [ + "tests/integration/test_api.py", + "tests/integration/test_sensor.py", + ] + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [ + "tests/integration/test_api.py", + "tests/integration/test_sensor.py", + ] + + +def test_determine_integration_tests_component_only_affected_tests() -> None: + """Test that only tests using the changed component are returned.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/modbus/modbus.cpp"], + ), + patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}), + patch.object( + determine_jobs, "get_integration_test_files_for_components" + ) as mock_test_files, + ): + mock_test_files.return_value = [ + "tests/integration/test_uart_mock_modbus.py", + ] + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == ["tests/integration/test_uart_mock_modbus.py"] + # Verify it was called with the right component + mock_test_files.assert_called_once_with({"modbus"}) + + +def test_determine_integration_tests_infra_file_runs_all() -> None: + """Test that changing infrastructure files (conftest.py, etc.) runs all tests.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/conftest.py"], + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] + + +def test_determine_integration_tests_readme_does_not_run_all() -> None: + """Test that changing README.md does not trigger integration tests.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/README.md"], + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [] + + +def test_determine_integration_tests_changed_test_file() -> None: + """Test that changing a specific test file only runs that test.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/test_syslog.py"], + ), + patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}), + patch.object( + determine_jobs, + "get_integration_test_files_for_components", + return_value=[], + ), + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == ["tests/integration/test_syslog.py"] + + +def test_determine_integration_tests_changed_fixture_yaml() -> None: + """Test that changing a fixture YAML runs the corresponding test file.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/fixtures/uart_mock_modbus.yaml"], + ), + patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map, + patch.object( + determine_jobs, + "get_integration_test_files_for_components", + return_value=[], + ), + ): + mock_fixture_map.return_value = { + "uart_mock_modbus": frozenset( + {"tests/integration/test_uart_mock_modbus.py"} + ), + } + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == ["tests/integration/test_uart_mock_modbus.py"] + + +def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: + """Test that non-YAML changes under fixtures/ (e.g., external_components) run all tests.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=[ + "tests/integration/fixtures/external_components/test_component/__init__.py" + ], + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] @pytest.mark.parametrize( @@ -538,7 +663,7 @@ def test_count_changed_cpp_files_with_branch() -> None: def test_main_filters_components_without_tests( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -551,7 +676,7 @@ def test_main_filters_components_without_tests( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -631,7 +756,7 @@ def test_main_filters_components_without_tests( def test_main_detects_components_with_variant_tests( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -649,7 +774,7 @@ def test_main_detects_components_with_variant_tests( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -999,7 +1124,7 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: def test_clang_tidy_mode_full_scan( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1010,7 +1135,7 @@ def test_clang_tidy_mode_full_scan( """Test that full scan (hash changed) always uses split mode.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -1065,7 +1190,7 @@ def test_clang_tidy_mode_targeted_scan( component_count: int, files_per_component: int, expected_mode: str, - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1076,7 +1201,7 @@ def test_clang_tidy_mode_targeted_scan( """Test clang-tidy mode selection based on files_to_check count.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -1123,7 +1248,7 @@ def test_clang_tidy_mode_targeted_scan( def test_main_core_files_changed_still_detects_components( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1135,7 +1260,7 @@ def test_main_core_files_changed_still_detects_components( """Test that component changes are detected even when core files change.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = True + mock_determine_integration_tests.return_value = (True, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True @@ -1604,7 +1729,7 @@ def test_detect_platform_hint_from_filename_case_insensitive( def test_component_batching_beta_branch_40_per_batch( tmp_path: Path, - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1628,7 +1753,7 @@ def test_component_batching_beta_branch_40_per_batch( (comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}") # Setup mocks - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 781054eb3b9..e3802d2d51f 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -36,6 +36,7 @@ def clear_helpers_cache() -> None: """Clear cached functions before each test.""" helpers._get_github_event_data.cache_clear() helpers._get_changed_files_github_actions.cache_clear() + helpers.get_components_per_integration_fixture.cache_clear() @pytest.mark.parametrize( @@ -1111,7 +1112,7 @@ def test_get_components_from_integration_fixtures() -> None: "gpio", } - mock_yaml_file = Mock() + mock_yaml_file = Mock(stem="test_fixture") with ( patch("pathlib.Path.glob") as mock_glob, @@ -1133,7 +1134,7 @@ def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None: ".binary_filters": {"filters": [{"settle": "50ms"}]}, } - mock_yaml_file = Mock() + mock_yaml_file = Mock(stem="test_fixture") with ( patch("pathlib.Path.glob") as mock_glob, @@ -1148,6 +1149,31 @@ def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None: assert components == {"sensor", "esphome", "template"} +def test_get_integration_test_files_for_components_real_fixtures() -> None: + """Test that component changes map to the correct real integration test files. + + This test uses real fixtures to verify the mapping stays correct + as new tests are added. + """ + # modbus should include at least the modbus test + modbus_tests = helpers.get_integration_test_files_for_components({"modbus"}) + assert "tests/integration/test_uart_mock_modbus.py" in modbus_tests + + # ld2410 should include at least the ld2410 test + ld2410_tests = helpers.get_integration_test_files_for_components({"ld2410"}) + assert "tests/integration/test_uart_mock_ld2410.py" in ld2410_tests + + # syslog should include at least the syslog test + syslog_tests = helpers.get_integration_test_files_for_components({"syslog"}) + assert "tests/integration/test_syslog.py" in syslog_tests + + # A component not used by any fixture should return nothing + fake_tests = helpers.get_integration_test_files_for_components( + {"nonexistent_component_xyz"} + ) + assert fake_tests == [] + + @pytest.mark.parametrize( "output,expected", [ From 7cceb72cc310e784acd249ff1108793abe36293f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 13:23:41 -1000 Subject: [PATCH 065/279] [api] Inline force-variant ProtoSize calc methods (#14781) --- esphome/components/api/proto.h | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index d1c955b1fb9..814a3f44560 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -602,7 +602,7 @@ class ProtoSize { static constexpr uint32_t calc_sint32(uint32_t field_id_size, int32_t value) { return value ? field_id_size + varint(encode_zigzag32(value)) : 0; } - static constexpr uint32_t calc_sint32_force(uint32_t field_id_size, int32_t value) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_sint32_force(uint32_t field_id_size, int32_t value) { return field_id_size + varint(encode_zigzag32(value)); } static constexpr uint32_t calc_int64(uint32_t field_id_size, int64_t value) { @@ -614,13 +614,13 @@ class ProtoSize { static constexpr uint32_t calc_uint64(uint32_t field_id_size, uint64_t value) { return value ? field_id_size + varint(value) : 0; } - static constexpr uint32_t calc_uint64_force(uint32_t field_id_size, uint64_t value) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) { return field_id_size + varint(value); } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; } - static constexpr uint32_t calc_length_force(uint32_t field_id_size, size_t len) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_length_force(uint32_t field_id_size, size_t len) { return field_id_size + varint(static_cast(len)) + static_cast(len); } static constexpr uint32_t calc_sint64(uint32_t field_id_size, int64_t value) { @@ -638,7 +638,8 @@ class ProtoSize { static constexpr uint32_t calc_message(uint32_t field_id_size, uint32_t nested_size) { return nested_size ? field_id_size + varint(nested_size) + nested_size : 0; } - static constexpr uint32_t calc_message_force(uint32_t field_id_size, uint32_t nested_size) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_message_force(uint32_t field_id_size, + uint32_t nested_size) { return field_id_size + varint(nested_size) + nested_size; } }; From 86b79330815c75cb3cd980afecfe80eb87b1026a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:24:41 -0400 Subject: [PATCH 066/279] [esp32_rmt_led_strip][remote_transmitter][remote_receiver] Fix ESP-IDF 6.0 RMT compatibility (#14783) Co-authored-by: Claude Opus 4.6 --- .../components/esp32_rmt_led_strip/led_strip.cpp | 2 -- .../remote_receiver/remote_receiver_rmt.cpp | 1 - .../remote_transmitter/remote_transmitter_rmt.cpp | 13 +++++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 66b41931aac..ca97a181fd4 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -99,8 +99,6 @@ void ESP32RMTLEDStripLightOutput::setup() { channel.gpio_num = gpio_num_t(this->pin_); channel.mem_block_symbols = this->rmt_symbols_; channel.trans_queue_depth = 1; - channel.flags.io_loop_back = 0; - channel.flags.io_od_mode = 0; channel.flags.invert_out = this->invert_out_; channel.flags.with_dma = this->use_dma_; channel.intr_priority = 0; diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 96b23bd0f52..596608a4d07 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -44,7 +44,6 @@ void RemoteReceiverComponent::setup() { channel.intr_priority = 0; channel.flags.invert_in = 0; channel.flags.with_dma = this->with_dma_; - channel.flags.io_loop_back = 0; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { this->error_code_ = error; diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 71773e3ddf8..3c9a12d472f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -120,11 +120,13 @@ void RemoteTransmitterComponent::configure_rmt_() { channel.gpio_num = gpio_num_t(this->pin_->get_pin()); channel.mem_block_symbols = this->rmt_symbols_; channel.trans_queue_depth = 1; - channel.flags.io_loop_back = open_drain; - channel.flags.io_od_mode = open_drain; channel.flags.invert_out = 0; channel.flags.with_dma = this->with_dma_; channel.intr_priority = 0; +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0) + channel.flags.io_loop_back = open_drain; + channel.flags.io_od_mode = open_drain; +#endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { this->error_code_ = error; @@ -136,6 +138,13 @@ void RemoteTransmitterComponent::configure_rmt_() { this->mark_failed(); return; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + if (open_drain) { + gpio_num_t gpio = gpio_num_t(this->pin_->get_pin()); + gpio_od_enable(gpio); + gpio_input_enable(gpio); + } +#endif if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { gpio_pullup_en(gpio_num_t(this->pin_->get_pin())); } else { From d6d3bbbad8f6e14a78c2f6c7505bdbfcbd3dad2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 13:28:34 -1000 Subject: [PATCH 067/279] [scheduler] Use integer math for interval offset calculation (#14755) --- esphome/core/scheduler.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 63e1006b03c..72b183384e3 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -105,10 +105,11 @@ static void validate_static_string(const char *name) { // avoid the main thread modifying the list while it is being accessed. // Calculate random offset for interval timers -// Extracted from set_timer_common_ to reduce code size - float math + random_float() -// only needed for intervals, not timeouts +// Extracted from set_timer_common_ to reduce code size - only needed for intervals, not timeouts uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { - return static_cast(std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float()); + uint32_t max_offset = std::min(delay / 2, MAX_INTERVAL_DELAY); + // Multiply-and-shift: uniform random in [0, max_offset) without floating point + return static_cast((static_cast(random_uint32()) * max_offset) >> 32); } // Check if a retry was already cancelled in items_ or to_add_ From 5e3c44d48fc5e7870725e5572accf0d0b0cd9de0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 13:28:55 -1000 Subject: [PATCH 068/279] [rp2040] Add CI check for boards.py freshness (#14754) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + esphome/components/rp2040/generate_boards.py | 12 +++- script/generate-rp2040-boards.py | 61 ++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100755 script/generate-rp2040-boards.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fedfebf393f..f7710589c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,7 @@ jobs: script/build_codeowners.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check + script/generate-rp2040-boards.py --check pytest: name: Run pytest diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2040/generate_boards.py index a0e3699f37b..7ea02d185e9 100644 --- a/esphome/components/rp2040/generate_boards.py +++ b/esphome/components/rp2040/generate_boards.py @@ -6,6 +6,7 @@ Usage: python esphome/components/rp2040/generate_boards.py import json from pathlib import Path import re +import subprocess import sys from jinja2 import Environment, FileSystemLoader @@ -157,7 +158,7 @@ def generate(arduino_pico_path: Path) -> str: board_pins, boards = load_boards(arduino_pico_path) template = _jinja_env.get_template("boards.jinja2") - return template.render( + content = template.render( cyw43_gpio_offset=CYW43_GPIO_OFFSET, cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, default_max_pin=DEFAULT_MAX_PIN, @@ -165,6 +166,15 @@ def generate(arduino_pico_path: Path) -> str: boards=sorted(boards.items()), ) + # Format output to match pre-commit ruff formatting + result = subprocess.run( + [sys.executable, "-m", "ruff", "format", "--stdin-filename", "boards.py"], + input=content.encode(), + capture_output=True, + check=True, + ) + return result.stdout.decode() + def main(): if len(sys.argv) < 2: diff --git a/script/generate-rp2040-boards.py b/script/generate-rp2040-boards.py new file mode 100755 index 00000000000..1b4846fd2b8 --- /dev/null +++ b/script/generate-rp2040-boards.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys +import tempfile + +from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +from esphome.components.rp2040.generate_boards import generate +from esphome.helpers import write_file_if_changed + +ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}" +root: Path = Path(__file__).parent.parent +boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py" + + +def main(check: bool) -> None: + with tempfile.TemporaryDirectory() as tempdir: + subprocess.run( + [ + "git", + "clone", + "-q", + "-c", + "advice.detachedHead=false", + "--depth", + "1", + "--branch", + version_tag, + "https://github.com/earlephilhower/arduino-pico", + tempdir, + ], + check=True, + ) + + content: str = generate(Path(tempdir)) + + if check: + existing_content: str = boards_file_path.read_text(encoding="utf-8") + if existing_content != content: + print("esphome/components/rp2040/boards.py is not up to date.") + print("Please run `script/generate-rp2040-boards.py`") + sys.exit(1) + print("esphome/components/rp2040/boards.py is up to date") + elif write_file_if_changed(boards_file_path, content): + print("RP2040 boards updated successfully.") + + +if __name__ == "__main__": + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument( + "--check", + help="Check if the boards.py file is up to date.", + action="store_true", + ) + args: argparse.Namespace = parser.parse_args() + main(args.check) From fcf5637aa5f42823d14015ec10c8aced123c1842 Mon Sep 17 00:00:00 2001 From: leccelecce <24962424+leccelecce@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:15:54 +0000 Subject: [PATCH 069/279] [online_image] Log download duration in milliseconds instead of seconds (#14803) --- esphome/components/online_image/online_image.cpp | 6 +++--- esphome/components/online_image/online_image.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index da866599c9d..22bf6a3056f 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -129,7 +129,7 @@ void OnlineImage::update() { } ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size); - this->start_time_ = ::time(nullptr); + this->start_time_ = millis(); this->enable_loop(); } @@ -155,8 +155,8 @@ void OnlineImage::loop() { // Finalize decoding this->end_decode(); - ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 "s", this->downloader_->get_bytes_read(), - (uint32_t) (::time(nullptr) - this->start_time_)); + ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), + millis() - this->start_time_); // Save caching headers this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index c7c80c7c667..12c25645260 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -97,7 +97,7 @@ class OnlineImage : public PollingComponent, */ std::string last_modified_ = ""; - time_t start_time_; + uint32_t start_time_{0}; }; template class OnlineImageSetUrlAction : public Action { From 0716c9f7227873bc236009ce5438cf0974bffe53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 08:12:04 -1000 Subject: [PATCH 070/279] [core] Inline LwIPLock as no-op on platforms without lwIP core locking (#14787) --- esphome/components/esp8266/helpers.cpp | 4 +--- esphome/components/libretiny/helpers.cpp | 4 +--- esphome/components/zephyr/core.cpp | 4 +--- esphome/core/helpers.h | 22 +++++++++++++++------- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp8266/helpers.cpp b/esphome/components/esp8266/helpers.cpp index 036594fa178..4a64ae181e3 100644 --- a/esphome/components/esp8266/helpers.cpp +++ b/esphome/components/esp8266/helpers.cpp @@ -22,9 +22,7 @@ void Mutex::unlock() {} IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } -// ESP8266 doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// ESP8266 LwIPLock is defined inline as a no-op in helpers.h void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) wifi_get_macaddr(STATION_IF, mac); diff --git a/esphome/components/libretiny/helpers.cpp b/esphome/components/libretiny/helpers.cpp index 37ae0fb455a..21913e4a16d 100644 --- a/esphome/components/libretiny/helpers.cpp +++ b/esphome/components/libretiny/helpers.cpp @@ -26,9 +26,7 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); } IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); } -// LibreTiny doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// LibreTiny LwIPLock is defined inline as a no-op in helpers.h void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) WiFi.macAddress(mac); diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index eee7fb3f4f8..1d105a10572 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -76,9 +76,7 @@ void Mutex::unlock() { k_mutex_unlock(static_cast(this->handle_)); } IRAM_ATTR InterruptLock::InterruptLock() { state_ = irq_lock(); } IRAM_ATTR InterruptLock::~InterruptLock() { irq_unlock(state_); } -// Zephyr doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// Zephyr LwIPLock is defined inline as a no-op in helpers.h uint32_t random_uint32() { return rand(); } // NOLINT(cert-msc30-c, cert-msc50-cpp) bool random_bytes(uint8_t *data, size_t len) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 9828df29cb4..22672087524 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1930,19 +1930,27 @@ class InterruptLock { /** Helper class to lock the lwIP TCPIP core when making lwIP API calls from non-TCPIP threads. * - * This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled. - * It ensures thread-safe access to lwIP APIs. + * This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled, + * and on RP2040 when CYW43 WiFi is active (cyw43_arch_lwip_begin/end). * - * @note This follows the same pattern as InterruptLock - platform-specific implementations in helpers.cpp + * On platforms without lwIP core locking (ESP8266, LibreTiny, Zephyr), + * this is a no-op defined inline so the compiler can eliminate all call overhead. */ class LwIPLock { public: - LwIPLock(); - ~LwIPLock(); - - // Delete copy constructor and copy assignment operator to prevent accidental copying LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; + +#if defined(USE_ESP32) || defined(USE_RP2040) + // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp + LwIPLock(); + ~LwIPLock(); +#else + // No lwIP core locking — inline no-ops (empty bodies instead of = default + // to prevent clang-tidy unused-variable warnings at call sites) + LwIPLock() {} + ~LwIPLock() {} +#endif }; /** Helper class to request `loop()` to be called as fast as possible. From 0043be616529e78cf7b9717f9fadf50749b904d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 08:13:01 -1000 Subject: [PATCH 071/279] [core] Inline trivial EntityBase accessors (#14782) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/core/entity_base.cpp | 5 ----- esphome/core/entity_base.h | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 818dae06de1..a47af1dd93c 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,9 +8,6 @@ namespace esphome { static const char *const TAG = "entity_base"; -// Entity Name -const StringRef &EntityBase::get_name() const { return this->name_; } - void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { @@ -176,8 +173,6 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } - // Migrate preference data from old_key to new_key if they differ. // This helper is exposed so callers with custom key computation (like TextPrefs) // can use it for manual migration. See: https://github.com/esphome/backlog/issues/85 diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index cccbafd2c36..012a62f1c08 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -68,7 +68,7 @@ static constexpr uint8_t ENTITY_FIELD_ENTITY_CATEGORY_SHIFT = 26; class EntityBase { public: // Get the name of this Entity - const StringRef &get_name() const; + const StringRef &get_name() const { return this->name_; } // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } @@ -86,7 +86,7 @@ class EntityBase { std::string get_object_id() const; // Get the unique Object ID of this Entity - uint32_t get_object_id_hash(); + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) From f2968e044903ca35db4da1e1773323c74b19fee2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 08:13:50 -1000 Subject: [PATCH 072/279] [api] Reduce API code size with buffer and nodelay optimizations (#14797) --- esphome/components/api/api_buffer.h | 6 +++++ esphome/components/api/api_connection.cpp | 3 +-- esphome/components/api/api_connection.h | 6 ++--- esphome/components/api/api_frame_helper.h | 29 ++++++++++------------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 00801e3ee58..1d0cccf61ca 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -44,6 +44,12 @@ class APIBuffer { this->reserve(n); this->size_ = n; // no zero-fill } + /// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size. + /// Single grow_ check regardless of argument order. + inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { + this->reserve(std::max(reserve_size, new_size)); + this->size_ = new_size; + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dea3ba5460b..d55b5dffb6a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2025,8 +2025,7 @@ uint16_t APIConnection::encode_to_buffer(uint32_t calculated_size, MessageEncode // Batch message second or later // Add padding for previous message footer + this message header size_t current_size = shared_buf.size(); - shared_buf.reserve(current_size + total_calculated_size); - shared_buf.resize(current_size + footer_size + header_padding); + shared_buf.reserve_and_resize(current_size + total_calculated_size, current_size + footer_size + header_padding); } // Pre-resize buffer to include payload, then encode through raw pointer diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 68f698d1902..85c8e777a94 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -305,9 +305,9 @@ class APIConnection final : public APIServerConnectionBase { // Reserve space for header padding + message + footer // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) - shared_buf.reserve(total_size); - // Resize to add header padding so message encoding starts at the correct position - shared_buf.resize(header_padding); + // Reserve full size but only set initial size to header padding + // so message encoding starts at the correct position + shared_buf.reserve_and_resize(total_size, header_padding); } // Convenience overload - computes frame overhead internally diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 5e07ad43a93..b2561f2b328 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -147,22 +147,18 @@ class APIFrameHelper { // void set_nodelay_for_message(bool is_log_message) { if (!is_log_message) { - if (this->nodelay_state_ != NODELAY_ON) { + if (this->nodelay_counter_) { this->set_nodelay_raw_(true); - this->nodelay_state_ = NODELAY_ON; + this->nodelay_counter_ = 0; } return; } - - // Log messages: state transitions -1 -> 1 -> ... -> LOG_NAGLE_COUNT -> -1 (flush) - if (this->nodelay_state_ == NODELAY_ON) { + // Log message: enable Nagle on first, flush after LOG_NAGLE_COUNT + if (!this->nodelay_counter_) this->set_nodelay_raw_(false); - this->nodelay_state_ = 1; - } else if (this->nodelay_state_ >= LOG_NAGLE_COUNT) { + if (++this->nodelay_counter_ > LOG_NAGLE_COUNT) { this->set_nodelay_raw_(true); - this->nodelay_state_ = NODELAY_ON; - } else { - this->nodelay_state_++; + this->nodelay_counter_ = 0; } } virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; @@ -258,18 +254,17 @@ class APIFrameHelper { uint8_t tx_buf_head_{0}; uint8_t tx_buf_tail_{0}; uint8_t tx_buf_count_{0}; - // Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled - // (immediate send). Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. - // After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset. + // Nagle batching counter for log messages. 0 means NODELAY is enabled (immediate send). + // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. + // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. - static constexpr int8_t NODELAY_ON = -1; #ifdef USE_ESP8266 - static constexpr int8_t LOG_NAGLE_COUNT = 2; + static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else - static constexpr int8_t LOG_NAGLE_COUNT = 3; + static constexpr uint8_t LOG_NAGLE_COUNT = 3; #endif - int8_t nodelay_state_{NODELAY_ON}; + uint8_t nodelay_counter_{0}; // Internal helper to set TCP_NODELAY socket option void set_nodelay_raw_(bool enable) { From ca279110c9157da5026451839e4a9b8b9724a69b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 08:31:50 -1000 Subject: [PATCH 073/279] [output] Inline trivial FloatOutput accessors (#14786) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/output/float_output.cpp | 6 ------ esphome/components/output/float_output.h | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index 3b83c857165..46014e0903a 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -11,16 +11,10 @@ void FloatOutput::set_max_power(float max_power) { this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to MIN>=MAX>=1.0 } -float FloatOutput::get_max_power() const { return this->max_power_; } - void FloatOutput::set_min_power(float min_power) { this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0>=MIN>=MAX } -void FloatOutput::set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } - -float FloatOutput::get_min_power() const { return this->min_power_; } - void FloatOutput::set_level(float state) { state = clamp(state, 0.0f, 1.0f); diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 3e2b3ada8d7..5225f88c669 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -48,9 +48,9 @@ class FloatOutput : public BinaryOutput { /** Sets this output to ignore min_power for a 0 state * - * @param zero True if a 0 state should mean 0 and not min_power. + * @param zero_means_zero True if a 0 state should mean 0 and not min_power. */ - void set_zero_means_zero(bool zero_means_zero); + void set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } /** Set the level of this float output, this is called from the front-end. * @@ -70,10 +70,10 @@ class FloatOutput : public BinaryOutput { // (In most use cases you won't need these) /// Get the maximum power output. - float get_max_power() const; + float get_max_power() const { return this->max_power_; } /// Get the minimum power output. - float get_min_power() const; + float get_min_power() const { return this->min_power_; } protected: /// Implement BinarySensor's write_enabled; this should never be called. From f12531e7e0b2a2702d3a68cbf95515839c885377 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:32:17 -0400 Subject: [PATCH 074/279] [esp32_camera] Bump esp32-camera to 2.1.5 (#14806) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/camera_encoder/__init__.py | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 89181d27b4a..a0c59a517a7 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 3a5d87792b6..afab849a7c6 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -400,7 +400,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index df651ae15dd..d83a71624c9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -8,7 +8,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.1 + version: 2.1.5 espressif/mdns: version: 1.10.0 espressif/esp_wifi_remote: From c52042e023c6178801a1c74e9480cfe4b9747689 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:01:29 -0400 Subject: [PATCH 075/279] [tinyusb][usb_cdc_acm] Bump esp_tinyusb to 2.1.1 (#14796) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/tinyusb/__init__.py | 2 +- esphome/components/tinyusb/tinyusb_component.cpp | 12 ++++++++---- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 7 +++---- esphome/idf_component.yml | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 90043e969c1..df94ad75346 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -54,7 +54,7 @@ async def to_code(config): if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - add_idf_component(name="espressif/esp_tinyusb", ref="1.7.6~1") + add_idf_component(name="espressif/esp_tinyusb", ref="2.1.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 19bb545c4b5..7f8fea52647 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -16,10 +16,14 @@ void TinyUSB::setup() { } this->tusb_cfg_ = { - .descriptor = &this->usb_descriptor_, - .string_descriptor = this->string_descriptor_, - .string_descriptor_count = SIZE, - .external_phy = false, + .port = TINYUSB_PORT_FULL_SPEED_0, + .phy = {.skip_setup = false}, + .descriptor = + { + .device = &this->usb_descriptor_, + .string = this->string_descriptor_, + .string_count = SIZE, + }, }; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 624f41cf8c6..020542e7490 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -8,7 +8,7 @@ #include #include "freertos/ringbuf.h" -#include "tusb_cdc_acm.h" +#include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 1a36ef9f31f..583aa77d063 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -11,7 +11,7 @@ #include "esp_log.h" #include "tusb.h" -#include "tusb_cdc_acm.h" +#include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { @@ -140,7 +140,6 @@ void USBCDCACMInstance::setup() { // Configure this CDC interface const tinyusb_config_cdcacm_t acm_cfg = { - .usb_dev = TINYUSB_USBDEV_0, .cdc_port = static_cast(this->itf_), .callback_rx = &tinyusb_cdc_rx_callback, .callback_rx_wanted_char = NULL, @@ -148,9 +147,9 @@ void USBCDCACMInstance::setup() { .callback_line_coding_changed = &tinyusb_cdc_line_coding_changed_callback, }; - esp_err_t result = tusb_cdc_acm_init(&acm_cfg); + esp_err_t result = tinyusb_cdcacm_init(&acm_cfg); if (result != ESP_OK) { - ESP_LOGE(TAG, "tusb_cdc_acm_init failed: %d", result); + ESP_LOGE(TAG, "tinyusb_cdcacm_init failed: %d", result); this->parent_->mark_failed(); return; } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index d83a71624c9..bb94de7e052 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -30,7 +30,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp_tinyusb: - version: "1.7.6~1" + version: "2.1.1" rules: - if: "target in [esp32s2, esp32s3, esp32p4]" esphome/esp-hub75: From 417858f09816f6d7ec726b11155eaeca63b8d9dc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:01:49 -0400 Subject: [PATCH 076/279] [psram] Add ESP32-C61 PSRAM support (#14795) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/psram/__init__.py | 3 +++ tests/components/psram/test.esp32-c61-idf.yaml | 7 +++++++ 2 files changed, 10 insertions(+) create mode 100644 tests/components/psram/test.esp32-c61-idf.yaml diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 39afb407f10..ccf35b851cc 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -8,6 +8,7 @@ from esphome.components.esp32 import ( CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, VARIANT_ESP32, VARIANT_ESP32C5, + VARIANT_ESP32C61, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -53,6 +54,7 @@ CONF_ENABLE_ECC = "enable_ecc" SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32C5: (TYPE_QUAD,), + VARIANT_ESP32C61: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), VARIANT_ESP32P4: (TYPE_HEX,), @@ -62,6 +64,7 @@ SPIRAM_MODES = { SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32C5: (40, 80, 120), + VARIANT_ESP32C61: (40, 80), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), VARIANT_ESP32P4: (20, 100, 200), diff --git a/tests/components/psram/test.esp32-c61-idf.yaml b/tests/components/psram/test.esp32-c61-idf.yaml new file mode 100644 index 00000000000..d443aab9518 --- /dev/null +++ b/tests/components/psram/test.esp32-c61-idf.yaml @@ -0,0 +1,7 @@ +esp32: + framework: + type: esp-idf + +psram: + speed: 80MHz + ignore_not_found: false From 271b423b227e8d92938f58ed126246c61afa3fae Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:01:58 -0400 Subject: [PATCH 077/279] [psram] Fix ESP-IDF 6.0 compatibility for PSRAM sdkconfig options (#14794) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/psram/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index ccf35b851cc..9b364584ff3 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -181,9 +181,6 @@ async def to_code(config): if config[CONF_MODE] == TYPE_OCTAL: cg.add_platformio_option("board_build.arduino.memory_type", "qio_opi") - add_idf_sdkconfig_option( - f"CONFIG_{get_esp32_variant().upper()}_SPIRAM_SUPPORT", True - ) add_idf_sdkconfig_option("CONFIG_SOC_SPIRAM_SUPPORTED", True) add_idf_sdkconfig_option("CONFIG_SPIRAM", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_USE", True) @@ -198,11 +195,19 @@ async def to_code(config): speed = int(config[CONF_SPEED][:-3]) add_idf_sdkconfig_option(f"CONFIG_SPIRAM_SPEED_{speed}M", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) - if config[CONF_MODE] == TYPE_OCTAL and speed == 120: - add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): + if speed == 120: + variant = get_esp32_variant() + # On chips with MSPI timing tuning, FLASH and PSRAM share the core + # clock so flash frequency must match PSRAM frequency. + # ESP32 and ESP32-S2 don't have this constraint. + if variant not in (VARIANT_ESP32, VARIANT_ESP32S2): + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) + if config[CONF_MODE] == TYPE_OCTAL and CORE.data[KEY_CORE][ + KEY_FRAMEWORK_VERSION + ] >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( - "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True + "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", + True, ) if config[CONF_ENABLE_ECC]: add_idf_sdkconfig_option("CONFIG_SPIRAM_ECC_ENABLE", True) From d4e1e32a300733b205c5da85d115e3c7c4df4336 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:02:06 -0400 Subject: [PATCH 078/279] [mipi_dsi] Fix ESP-IDF 6.0 compatibility for use_dma2d flag (#14792) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/mipi_dsi/mipi_dsi.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 7103e0868dc..e8e9ca2bfbf 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -87,7 +87,9 @@ void MIPI_DSI::setup() { .vsync_front_porch = this->vsync_front_porch_, }, .flags = { +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0) .use_dma2d = true, +#endif }}; // clang-format on err = esp_lcd_new_panel_dpi(this->bus_handle_, &dpi_config, &this->handle_); @@ -95,6 +97,13 @@ void MIPI_DSI::setup() { this->smark_failed(LOG_STR("esp_lcd_new_panel_dpi failed"), err); return; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + err = esp_lcd_dpi_panel_enable_dma2d(this->handle_); + if (err != ESP_OK) { + this->smark_failed(LOG_STR("esp_lcd_dpi_panel_enable_dma2d failed"), err); + return; + } +#endif if (this->reset_pin_ != nullptr) { this->reset_pin_->setup(); this->reset_pin_->digital_write(true); From b126f3af3b3949d7e9d9b2cddad211e0fc7bdd29 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:02:13 -0400 Subject: [PATCH 079/279] [ledc] Fix ESP-IDF 6.0 compatibility for peripheral reset (#14790) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/ledc/ledc_output.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 592fc7bd0c1..a3d1e4d3922 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -5,10 +5,9 @@ #include #include +#include #include -#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) #include -#endif #define CLOCK_FREQUENCY 80e6f @@ -161,7 +160,14 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + PERIPH_RCC_ATOMIC() { + ledc_ll_enable_reset_reg(true); + ledc_ll_enable_reset_reg(false); + } +#else periph_module_reset(PERIPH_LEDC_MODULE); +#endif ledc_peripheral_reset_done = true; } From 158a119a5a6a4591e531739aae6e5f9fb1cb3880 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 10:43:04 -1000 Subject: [PATCH 080/279] [sha256] Migrate to PSA Crypto API for ESP-IDF 6.0 (#14809) --- esphome/components/sha256/sha256.cpp | 23 ++++++++++++++++++++++- esphome/components/sha256/sha256.h | 19 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 23995e6534b..079665c9596 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -8,7 +8,28 @@ namespace esphome::sha256 { -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_SHA256_PSA) + +// ESP-IDF 6.0 ships mbedtls 4.0 which removed the legacy mbedtls_sha256_* API. +// Use the PSA Crypto API instead. PSA crypto is auto-initialized by ESP-IDF +// at startup, so no psa_crypto_init() call is needed. + +SHA256::~SHA256() { psa_hash_abort(&this->op_); } + +void SHA256::init() { + psa_hash_abort(&this->op_); + this->op_ = PSA_HASH_OPERATION_INIT; + psa_hash_setup(&this->op_, PSA_ALG_SHA_256); +} + +void SHA256::add(const uint8_t *data, size_t len) { psa_hash_update(&this->op_, data, len); } + +void SHA256::calculate() { + size_t hash_length; + psa_hash_finish(&this->op_, this->digest_, sizeof(this->digest_), &hash_length); +} + +#elif defined(USE_SHA256_MBEDTLS) // CRITICAL ESP32 HARDWARE SHA ACCELERATION REQUIREMENTS (IDF 5.5.x): // diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index bafb359485c..0f995fcd916 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -10,7 +10,20 @@ #include #include "esphome/core/hash_base.h" -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls_sha256_* API. +// Use the PSA Crypto API instead. PSA crypto is auto-initialized by +// ESP-IDF at startup (esp_psa_crypto_init.c, priority 104). +#define USE_SHA256_PSA +#include +#else +#define USE_SHA256_MBEDTLS +#include "mbedtls/sha256.h" +#endif +#elif defined(USE_LIBRETINY) +#define USE_SHA256_MBEDTLS #include "mbedtls/sha256.h" #elif defined(USE_ESP8266) || defined(USE_RP2040) #include @@ -51,7 +64,9 @@ class SHA256 : public esphome::HashBase { size_t get_size() const override { return 32; } protected: -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_SHA256_PSA) + psa_hash_operation_t op_ = PSA_HASH_OPERATION_INIT; +#elif defined(USE_SHA256_MBEDTLS) // The mbedtls context for ESP32-S3 hardware SHA requires proper alignment and stack frame constraints. // See class documentation above for critical requirements. mbedtls_sha256_context ctx_{}; From 27942f19733d442998bd457531ffbc58ac3e8f63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 11:05:39 -1000 Subject: [PATCH 081/279] [helpers] Replace deprecated std::is_trivial in FixedRingBuffer (#14808) --- esphome/core/helpers.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 22672087524..c2f4cace9a9 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -417,7 +417,7 @@ template::max()> FixedRingBuffer() = default; ~FixedRingBuffer() { - if constexpr (std::is_trivial::value) { + if constexpr (std::is_trivially_copyable::value && std::is_trivially_default_constructible::value) { ::operator delete(this->data_); } else { delete[] this->data_; @@ -430,7 +430,7 @@ template::max()> /// Allocate capacity - can only be called once void init(index_type capacity) { - if constexpr (std::is_trivial::value) { + if constexpr (std::is_trivially_copyable::value && std::is_trivially_default_constructible::value) { // Raw allocation without initialization (elements are written before read) // NOLINTNEXTLINE(bugprone-sizeof-expression) this->data_ = static_cast(::operator new(capacity * sizeof(T))); From 447c4669b1d7c3e66becaeeaaaf83071f07928ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 11:26:20 -1000 Subject: [PATCH 082/279] [esp32] Disable SHA-512 in mbedTLS on IDF 6.0+ and add idf_version() helper (#14810) --- esphome/components/esp32/__init__.py | 53 +++++++++++++++++++-- esphome/components/esp32/const.py | 1 + esphome/components/esp32_hosted/__init__.py | 15 ++---- esphome/components/ethernet/__init__.py | 10 ++-- esphome/components/psram/__init__.py | 7 +-- 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 475de6aa3e4..eaa9aa163d9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -59,6 +59,7 @@ from .const import ( # noqa KEY_EXTRA_BUILD_FILES, KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, @@ -420,9 +421,20 @@ def set_core_data(config): CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = excluded # Initialize Arduino library tracking - cg.add_library() auto-enables libraries CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES] = set() - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( - config[CONF_FRAMEWORK][CONF_VERSION] - ) + framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION]) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver + + # Store the underlying IDF version for framework-agnostic checks + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver + elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver + else: + raise cv.Invalid( + f"Arduino version {framework_ver} has no known ESP-IDF version mapping. " + "Please update ARDUINO_IDF_VERSION_LOOKUP.", + path=[CONF_FRAMEWORK, CONF_VERSION], + ) CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] @@ -974,6 +986,7 @@ KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" +KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" def require_vfs_select() -> None: @@ -1043,6 +1056,25 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_sha512() -> None: + """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. + + Call this from components that need to verify TLS certificates or signatures + using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C + and CONFIG_MBEDTLS_SHA512_C from being disabled. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True + + +def idf_version() -> cv.Version: + """Return the underlying ESP-IDF version regardless of framework choice. + + For ESP-IDF builds this is the framework version directly. + For Arduino builds this is the mapped IDF version from ARDUINO_IDF_VERSION_LOOKUP. + """ + return CORE.data[KEY_ESP32][KEY_IDF_VERSION] + + def require_fatfs() -> None: """Mark that FATFS support is required by a component. @@ -1802,6 +1834,21 @@ async def to_code(config): elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) + # Disable SHA-384 and SHA-512 in mbedTLS + # ESPHome doesn't use either algorithm. SHA-384 shares the same + # compression function as SHA-512 (mbedtls_internal_sha512_process), + # so both must be disabled to eliminate the ~3KB software fallback + # that IDF 6.0's PSA parallel engine always links in. + # On IDF < 6.0 these are a single config and hardware-only (no + # software fallback), so there was no code size cost to leaving + # them enabled. + # Components that need SHA-384/SHA-512 can call require_mbedtls_sha512() + if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get( + KEY_MBEDTLS_SHA512_REQUIRED, False + ): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 7874c1c759c..d0d00723fcb 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -15,6 +15,7 @@ KEY_PATH = "path" KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" +KEY_IDF_VERSION = "idf_version" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index a51ae2cd666..3f9185745dd 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -4,14 +4,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 import esphome.config_validation as cv -from esphome.const import ( - CONF_CLK_PIN, - CONF_RESET_PIN, - CONF_VARIANT, - KEY_CORE, - KEY_FRAMEWORK_VERSION, -) -from esphome.core import CORE +from esphome.const import CONF_CLK_PIN, CONF_RESET_PIN, CONF_VARIANT from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] @@ -100,9 +93,9 @@ async def to_code(config): int(config[CONF_SDIO_FREQUENCY] // 1000), ) - framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" - if framework_ver >= cv.Version(5, 5, 0): + idf_ver = esp32.idf_version() + os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" + if idf_ver >= cv.Version(5, 5, 0): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 935d2004d49..e520c0e914d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, include_builtin_idf_component, ) from esphome.components.network import ip_address_literal @@ -176,13 +177,12 @@ ManualIP = ethernet_ns.struct("ManualIP") def _is_framework_spi_polling_mode_supported(): # SPI Ethernet without IRQ feature is added in # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) - # Note: Arduino now uses ESP-IDF as a component, so we only check IDF version - framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if framework_version >= cv.Version(5, 3, 0): + ver = idf_version() + if ver >= cv.Version(5, 3, 0): return True - if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): + if cv.Version(5, 3, 0) > ver >= cv.Version(5, 2, 1): return True - if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 + if cv.Version(5, 2, 0) > ver >= cv.Version(5, 1, 4): # noqa: SIM103 return True return False diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 9b364584ff3..86c17ce9ca0 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) import esphome.config_validation as cv from esphome.const import ( @@ -23,8 +24,6 @@ from esphome.const import ( CONF_ID, CONF_MODE, CONF_SPEED, - KEY_CORE, - KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, ) from esphome.core import CORE @@ -202,9 +201,7 @@ async def to_code(config): # ESP32 and ESP32-S2 don't have this constraint. if variant not in (VARIANT_ESP32, VARIANT_ESP32S2): add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - if config[CONF_MODE] == TYPE_OCTAL and CORE.data[KEY_CORE][ - KEY_FRAMEWORK_VERSION - ] >= cv.Version(5, 4, 0): + if config[CONF_MODE] == TYPE_OCTAL and idf_version() >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True, From 234ca7c9514351e059bb3cea5a1c0d15fea88f5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:17:32 -1000 Subject: [PATCH 083/279] [debug] Fix shared buffer between reset reason and wakeup cause (#14813) --- esphome/components/debug/debug_component.h | 3 ++- esphome/components/debug/debug_esp32.cpp | 9 +++++---- esphome/components/debug/debug_esp8266.cpp | 2 +- esphome/components/debug/debug_host.cpp | 2 +- esphome/components/debug/debug_libretiny.cpp | 2 +- esphome/components/debug/debug_rp2040.cpp | 2 +- esphome/components/debug/debug_zephyr.cpp | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index e4f4bb36eba..3da6b800c6f 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -18,6 +18,7 @@ namespace debug { static constexpr size_t DEVICE_INFO_BUFFER_SIZE = 256; static constexpr size_t RESET_REASON_BUFFER_SIZE = 128; +static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h @@ -94,7 +95,7 @@ class DebugComponent : public PollingComponent { #endif // USE_TEXT_SENSOR const char *get_reset_reason_(std::span buffer); - const char *get_wakeup_cause_(std::span buffer); + const char *get_wakeup_cause_(std::span buffer); uint32_t get_free_heap_(); size_t get_device_info_(std::span buffer, size_t pos); void update_platform_(); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 6898621dd05..c9df4fdf210 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -98,7 +98,7 @@ static const char *const WAKEUP_CAUSES[] = { "BT", }; -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { const char *wake_reason; unsigned reason = esp_sleep_get_wakeup_cause(); if (reason < sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0])) { @@ -196,9 +196,10 @@ size_t DebugComponent::get_device_info_(std::span uint32_t cpu_freq_mhz = arch_get_cpu_freq_hz() / 1000000; pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32 " MHz", cpu_freq_mhz); - char reason_buffer[RESET_REASON_BUFFER_SIZE]; - const char *reset_reason = get_reset_reason_(std::span(reason_buffer)); - const char *wakeup_cause = get_wakeup_cause_(std::span(reason_buffer)); + char reset_buffer[RESET_REASON_BUFFER_SIZE]; + char wakeup_buffer[WAKEUP_CAUSE_BUFFER_SIZE]; + const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); + const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); uint8_t mac[6]; get_mac_address_raw(mac); diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 4df4aaa8513..0519ab72fe9 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -91,7 +91,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // ESP8266 doesn't have detailed wakeup cause like ESP32 return ""; } diff --git a/esphome/components/debug/debug_host.cpp b/esphome/components/debug/debug_host.cpp index 2fa88f0909c..0dfab86e4c4 100644 --- a/esphome/components/debug/debug_host.cpp +++ b/esphome/components/debug/debug_host.cpp @@ -7,7 +7,7 @@ namespace debug { const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 39269d6f2f2..1d458c602a6 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -12,7 +12,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return lt_heap_get_free(); } diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index 8dc84a26732..73f08492c86 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -67,7 +67,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index bd6432e9499..bf87b7ae3dc 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -53,7 +53,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // Zephyr doesn't have detailed wakeup cause like ESP32 return ""; } From cc4c13930f7ad862c17dbbd630169f09a5e9af1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:17:43 -1000 Subject: [PATCH 084/279] [hmac_sha256] Migrate to PSA Crypto MAC API for ESP-IDF 6.0 (#14814) --- .../components/hmac_sha256/hmac_sha256.cpp | 52 ++++++++++++++++++- esphome/components/hmac_sha256/hmac_sha256.h | 21 +++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp index 2146e961bc9..c113cb48a6f 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.cpp +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -7,7 +7,55 @@ namespace esphome::hmac_sha256 { constexpr size_t SHA256_DIGEST_SIZE = 32; -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_HMAC_SHA256_PSA) + +// ESP-IDF 6.0 ships mbedtls 4.0 which removed the legacy mbedtls_md HMAC API. +// Use the PSA Crypto MAC API instead. + +HmacSHA256::~HmacSHA256() { + psa_mac_abort(&this->op_); + psa_destroy_key(this->key_id_); +} + +void HmacSHA256::init(const uint8_t *key, size_t len) { + psa_mac_abort(&this->op_); + psa_destroy_key(this->key_id_); + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_HMAC); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_SIGN_MESSAGE); + psa_set_key_algorithm(&attributes, PSA_ALG_HMAC(PSA_ALG_SHA_256)); + psa_import_key(&attributes, key, len, &this->key_id_); + + this->op_ = PSA_MAC_OPERATION_INIT; + psa_mac_sign_setup(&this->op_, this->key_id_, PSA_ALG_HMAC(PSA_ALG_SHA_256)); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { psa_mac_update(&this->op_, data, len); } + +void HmacSHA256::calculate() { + size_t mac_length; + psa_mac_sign_finish(&this->op_, this->digest_, sizeof(this->digest_), &mac_length); +} + +void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); } + +void HmacSHA256::get_hex(char *output) { + format_hex_to(output, SHA256_DIGEST_SIZE * 2 + 1, this->digest_, SHA256_DIGEST_SIZE); +} + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { + return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0; +} + +bool HmacSHA256::equals_hex(const char *expected) { + char hex_output[SHA256_DIGEST_SIZE * 2 + 1]; + this->get_hex(hex_output); + hex_output[SHA256_DIGEST_SIZE * 2] = '\0'; + return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0; +} + +#elif defined(USE_HMAC_SHA256_MBEDTLS) HmacSHA256::~HmacSHA256() { mbedtls_md_free(&this->ctx_); } @@ -93,7 +141,7 @@ bool HmacSHA256::equals_bytes(const uint8_t *expected) { return this->ohash_.equ bool HmacSHA256::equals_hex(const char *expected) { return this->ohash_.equals_hex(expected); } -#endif // USE_ESP32 || USE_LIBRETINY +#endif // USE_HMAC_SHA256_PSA / USE_HMAC_SHA256_MBEDTLS } // namespace esphome::hmac_sha256 #endif diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h index 85622cac46b..22129b1182f 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.h +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -5,7 +5,19 @@ #include -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls_md HMAC API. +// Use the PSA Crypto MAC API instead. +#define USE_HMAC_SHA256_PSA +#include +#else +#define USE_HMAC_SHA256_MBEDTLS +#include "mbedtls/md.h" +#endif +#elif defined(USE_LIBRETINY) +#define USE_HMAC_SHA256_MBEDTLS #include "mbedtls/md.h" #else #include "esphome/components/sha256/sha256.h" @@ -45,7 +57,12 @@ class HmacSHA256 { bool equals_hex(const char *expected); protected: -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_HMAC_SHA256_PSA) + static constexpr size_t SHA256_DIGEST_SIZE = 32; + psa_mac_operation_t op_ = PSA_MAC_OPERATION_INIT; + mbedtls_svc_key_id_t key_id_ = MBEDTLS_SVC_KEY_ID_INIT; + uint8_t digest_[SHA256_DIGEST_SIZE]{}; +#elif defined(USE_HMAC_SHA256_MBEDTLS) static constexpr size_t SHA256_DIGEST_SIZE = 32; mbedtls_md_context_t ctx_{}; uint8_t digest_[SHA256_DIGEST_SIZE]{}; From 0edc0fd9c885da0ecc5184a80bae878c02777041 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:17:56 -1000 Subject: [PATCH 085/279] [esp32_ble_tracker] Migrate to PSA Crypto API for ESP-IDF 6.0 (#14811) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 44 ++++++++++++++++--- .../esp32_ble_tracker/esp32_ble_tracker.h | 7 +++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5a43cf7e49b..6dce70f8396 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -27,8 +27,14 @@ #include #endif +#ifdef USE_ESP32_BLE_DEVICE +#ifdef USE_BLE_TRACKER_PSA_AES +#include +#else #define MBEDTLS_AES_ALT #include +#endif +#endif // USE_ESP32_BLE_DEVICE // bt_trace.h #undef TAG @@ -738,23 +744,48 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { } bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { - uint8_t ecb_key[16]; - uint8_t ecb_plaintext[16]; - uint8_t ecb_ciphertext[16]; + static constexpr size_t AES_BLOCK_SIZE = 16; + static constexpr size_t AES_KEY_BITS = 128; + + uint8_t ecb_key[AES_BLOCK_SIZE]; + uint8_t ecb_plaintext[AES_BLOCK_SIZE]; + uint8_t ecb_ciphertext[AES_BLOCK_SIZE]; uint64_t addr64 = esp32_ble::ble_addr_to_uint64(this->address_); - memcpy(&ecb_key, irk, 16); - memset(&ecb_plaintext, 0, 16); + memcpy(&ecb_key, irk, AES_BLOCK_SIZE); + memset(&ecb_plaintext, 0, AES_BLOCK_SIZE); ecb_plaintext[13] = (addr64 >> 40) & 0xff; ecb_plaintext[14] = (addr64 >> 32) & 0xff; ecb_plaintext[15] = (addr64 >> 24) & 0xff; +#ifdef USE_BLE_TRACKER_PSA_AES + // Use PSA Crypto API (mbedtls 4.0 / IDF 6.0+) + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, AES_KEY_BITS); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_ECB_NO_PADDING); + + mbedtls_svc_key_id_t key_id; + if (psa_import_key(&attributes, ecb_key, AES_BLOCK_SIZE, &key_id) != PSA_SUCCESS) { + return false; + } + + size_t output_length; + psa_status_t status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, ecb_plaintext, AES_BLOCK_SIZE, + ecb_ciphertext, AES_BLOCK_SIZE, &output_length); + psa_destroy_key(key_id); + if (status != PSA_SUCCESS || output_length != AES_BLOCK_SIZE) { + return false; + } +#else + // Use legacy mbedtls AES API (IDF < 6.0) mbedtls_aes_context ctx = {0, 0, {0}}; mbedtls_aes_init(&ctx); - if (mbedtls_aes_setkey_enc(&ctx, ecb_key, 128) != 0) { + if (mbedtls_aes_setkey_enc(&ctx, ecb_key, AES_KEY_BITS) != 0) { mbedtls_aes_free(&ctx); return false; } @@ -765,6 +796,7 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { } mbedtls_aes_free(&ctx); +#endif return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7f1c2b0f7c8..f50ed107b6d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -12,6 +12,13 @@ #ifdef USE_ESP32 +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls AES API. +// Use the PSA Crypto API instead. +#define USE_BLE_TRACKER_PSA_AES +#endif + #include #include #include From efc508a82bf4edb19196761ee57cc388ec78d3b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:18:40 -1000 Subject: [PATCH 086/279] [dlms_meter] Migrate GCM to PSA AEAD API for ESP-IDF 6.0 (#14817) --- esphome/components/dlms_meter/dlms_meter.cpp | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index bd2150e8ddf..052a0f4d01f 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -3,9 +3,14 @@ #if defined(USE_ESP8266_FRAMEWORK_ARDUINO) #include #elif defined(USE_ESP32) +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else #include "mbedtls/esp_config.h" #include "mbedtls/gcm.h" #endif +#endif namespace esphome::dlms_meter { @@ -240,6 +245,35 @@ bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t m br_gcm_flip(&gcm_ctx); br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); #elif defined(USE_ESP32) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + + mbedtls_svc_key_id_t key_id; + bool decrypt_failed = true; + if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { + psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; + if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && + psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { + size_t outlen = 0; + if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && + outlen == message_length) { + decrypt_failed = false; + } + } + psa_aead_abort(&op); + psa_destroy_key(key_id); + } + if (decrypt_failed) { + ESP_LOGE(TAG, "Decryption failed"); + this->receive_buffer_.clear(); + return false; + } +#else size_t outlen = 0; mbedtls_gcm_context gcm_ctx; mbedtls_gcm_init(&gcm_ctx); @@ -252,6 +286,7 @@ bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t m this->receive_buffer_.clear(); return false; } +#endif #else #error "Invalid Platform" #endif From d7c42bc9ec72f02ca802f75152bb869734e30d05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:38:51 -1000 Subject: [PATCH 087/279] [debug] Fix ESP-IDF 6.0 compatibility for wakeup cause API (#14812) --- esphome/components/debug/debug_esp32.cpp | 85 ++++++++++++++++++------ 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index c9df4fdf210..aa379599c62 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include #include #include @@ -82,32 +83,74 @@ const char *DebugComponent::get_reset_reason_(std::span= ESP_IDF_VERSION_VAL(6, 0, 0) static const char *const WAKEUP_CAUSES[] = { - "undefined", - "undefined", - "external signal using RTC_IO", - "external signal using RTC_CNTL", - "timer", - "touchpad", - "ULP program", - "GPIO", - "UART", - "WIFI", - "COCPU int", - "COCPU crash", - "BT", + "undefined", // ESP_SLEEP_WAKEUP_UNDEFINED (0) + "undefined", // ESP_SLEEP_WAKEUP_ALL (1) + "external signal using RTC_IO", // ESP_SLEEP_WAKEUP_EXT0 (2) + "external signal using RTC_CNTL", // ESP_SLEEP_WAKEUP_EXT1 (3) + "timer", // ESP_SLEEP_WAKEUP_TIMER (4) + "touchpad", // ESP_SLEEP_WAKEUP_TOUCHPAD (5) + "ULP program", // ESP_SLEEP_WAKEUP_ULP (6) + "GPIO", // ESP_SLEEP_WAKEUP_GPIO (7) + "UART", // ESP_SLEEP_WAKEUP_UART (8) + "UART1", // ESP_SLEEP_WAKEUP_UART1 (9) + "UART2", // ESP_SLEEP_WAKEUP_UART2 (10) + "WIFI", // ESP_SLEEP_WAKEUP_WIFI (11) + "COCPU int", // ESP_SLEEP_WAKEUP_COCPU (12) + "COCPU crash", // ESP_SLEEP_WAKEUP_COCPU_TRAP_TRIG (13) + "BT", // ESP_SLEEP_WAKEUP_BT (14) + "VAD", // ESP_SLEEP_WAKEUP_VAD (15) + "VBAT under voltage", // ESP_SLEEP_WAKEUP_VBAT_UNDER_VOLT (16) }; +#else +static const char *const WAKEUP_CAUSES[] = { + "undefined", // ESP_SLEEP_WAKEUP_UNDEFINED (0) + "undefined", // ESP_SLEEP_WAKEUP_ALL (1) + "external signal using RTC_IO", // ESP_SLEEP_WAKEUP_EXT0 (2) + "external signal using RTC_CNTL", // ESP_SLEEP_WAKEUP_EXT1 (3) + "timer", // ESP_SLEEP_WAKEUP_TIMER (4) + "touchpad", // ESP_SLEEP_WAKEUP_TOUCHPAD (5) + "ULP program", // ESP_SLEEP_WAKEUP_ULP (6) + "GPIO", // ESP_SLEEP_WAKEUP_GPIO (7) + "UART", // ESP_SLEEP_WAKEUP_UART (8) + "WIFI", // ESP_SLEEP_WAKEUP_WIFI (9) + "COCPU int", // ESP_SLEEP_WAKEUP_COCPU (10) + "COCPU crash", // ESP_SLEEP_WAKEUP_COCPU_TRAP_TRIG (11) + "BT", // ESP_SLEEP_WAKEUP_BT (12) +}; +#endif const char *DebugComponent::get_wakeup_cause_(std::span buffer) { - const char *wake_reason; - unsigned reason = esp_sleep_get_wakeup_cause(); - if (reason < sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0])) { - wake_reason = WAKEUP_CAUSES[reason]; - } else { - wake_reason = "unknown source"; + static constexpr auto NUM_CAUSES = sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0]); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // IDF 6.0+ returns a bitmap of all wakeup sources + uint32_t causes = esp_sleep_get_wakeup_causes(); + if (causes == 0) { + return WAKEUP_CAUSES[0]; // "undefined" } - // Return the static string directly - no need to copy to buffer - return wake_reason; + char *p = buffer.data(); + char *end = p + buffer.size(); + *p = '\0'; + const char *sep = ""; + for (unsigned i = 0; i < NUM_CAUSES && p < end; i++) { + if (causes & (1U << i)) { + size_t needed = strlen(sep) + strlen(WAKEUP_CAUSES[i]); + if (p + needed >= end) { + break; + } + p += snprintf(p, end - p, "%s%s", sep, WAKEUP_CAUSES[i]); + sep = ", "; + } + } + return buffer.data(); +#else + unsigned reason = esp_sleep_get_wakeup_cause(); + if (reason < NUM_CAUSES) { + return WAKEUP_CAUSES[reason]; + } + return "unknown source"; +#endif } void DebugComponent::log_partition_info_() { From d37f8876d73a637045b3e65f4aca525572a5aab1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:39:07 -1000 Subject: [PATCH 088/279] [bthome_mithermometer][xiaomi_ble] Migrate CCM to PSA AEAD API for ESP-IDF 6.0 (#14816) --- .../bthome_mithermometer/bthome_ble.cpp | 37 ++++++++++++++++++ esphome/components/xiaomi_ble/xiaomi_ble.cpp | 39 +++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 2b73d8735c7..32278dbfbd4 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -10,7 +10,12 @@ #ifdef USE_ESP32 +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else #include "mbedtls/ccm.h" +#endif namespace esphome { namespace bthome_mithermometer { @@ -196,6 +201,37 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da const uint8_t *ciphertext = data.data() + 1; const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // PSA AEAD expects ciphertext + tag concatenated + // BLE advertisement max payload is 31 bytes, so this is always sufficient + static constexpr size_t MAX_CT_WITH_TAG = 32; + uint8_t ct_with_tag[MAX_CT_WITH_TAG]; + size_t ct_with_tag_size = ciphertext_size + BTHOME_MIC_SIZE; + memcpy(ct_with_tag, ciphertext, ciphertext_size); + memcpy(ct_with_tag + ciphertext_size, mic, BTHOME_MIC_SIZE); + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, BTHOME_BINDKEY_SIZE * 8); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE)); + + mbedtls_svc_key_id_t key_id; + if (psa_import_key(&attributes, this->bindkey_, BTHOME_BINDKEY_SIZE, &key_id) != PSA_SUCCESS) { + ESP_LOGVV(TAG, "psa_import_key() failed."); + return false; + } + + size_t plaintext_length; + psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE), + nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size, + payload.data(), ciphertext_size, &plaintext_length); + psa_destroy_key(key_id); + if (status != PSA_SUCCESS || plaintext_length != ciphertext_size) { + ESP_LOGVV(TAG, "BTHome decryption failed."); + return false; + } +#else mbedtls_ccm_context ctx; mbedtls_ccm_init(&ctx); @@ -213,6 +249,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret); return false; } +#endif return true; } diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 97a660f0e3a..2c1611d0c7d 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -5,7 +5,12 @@ #ifdef USE_ESP32 #include +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else #include "mbedtls/ccm.h" +#endif namespace esphome { namespace xiaomi_ble { @@ -314,6 +319,32 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1) memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // PSA AEAD expects ciphertext + tag concatenated + uint8_t ct_with_tag[sizeof(vector.ciphertext) + sizeof(vector.tag)]; + memcpy(ct_with_tag, vector.ciphertext, vector.datasize); + memcpy(ct_with_tag + vector.datasize, vector.tag, vector.tagsize); + size_t ct_with_tag_size = vector.datasize + vector.tagsize; + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, vector.keysize * 8); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, vector.tagsize)); + + mbedtls_svc_key_id_t key_id; + if (psa_import_key(&attributes, vector.key, vector.keysize, &key_id) != PSA_SUCCESS) { + ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): psa_import_key() failed."); + return false; + } + + size_t plaintext_length; + psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, vector.tagsize), + vector.iv, vector.ivsize, vector.authdata, vector.authsize, ct_with_tag, + ct_with_tag_size, vector.plaintext, vector.datasize, &plaintext_length); + psa_destroy_key(key_id); + bool decrypt_ok = (status == PSA_SUCCESS && plaintext_length == vector.datasize); +#else mbedtls_ccm_context ctx; mbedtls_ccm_init(&ctx); @@ -326,7 +357,11 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c ret = mbedtls_ccm_auth_decrypt(&ctx, vector.datasize, vector.iv, vector.ivsize, vector.authdata, vector.authsize, vector.ciphertext, vector.plaintext, vector.tag, vector.tagsize); - if (ret) { + mbedtls_ccm_free(&ctx); + bool decrypt_ok = (ret == 0); +#endif + + if (!decrypt_ok) { uint8_t mac_address[6] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); @@ -346,7 +381,6 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c ESP_LOGVV(TAG, " Iv : %s", format_hex_pretty_to(hex_buf, vector.iv, vector.ivsize)); ESP_LOGVV(TAG, " Cipher : %s", format_hex_pretty_to(hex_buf, vector.ciphertext, vector.datasize)); ESP_LOGVV(TAG, " Tag : %s", format_hex_pretty_to(hex_buf, vector.tag, vector.tagsize)); - mbedtls_ccm_free(&ctx); return false; } @@ -367,7 +401,6 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c ESP_LOGVV(TAG, " Plaintext : %s, Packet : %d", format_hex_pretty_to(hex_buf, raw.data() + cipher_pos, vector.datasize), static_cast(raw[4])); - mbedtls_ccm_free(&ctx); return true; } From fe9f19d9ed078db9eb3d82e2fb698c90dc9e6043 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:30:12 -0400 Subject: [PATCH 089/279] [mqtt] Fix ESP-IDF 6.0 compatibility for external MQTT component (#14822) --- esphome/components/mqtt/__init__.py | 8 +++++++- esphome/idf_component.yml | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index d110d7c1602..817f99375ea 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -3,7 +3,9 @@ from esphome.automation import Condition import esphome.codegen as cg from esphome.components import logger, socket from esphome.components.esp32 import ( + add_idf_component, add_idf_sdkconfig_option, + idf_version, include_builtin_idf_component, ) from esphome.config_helpers import filter_source_files_from_platform @@ -351,7 +353,11 @@ async def to_code(config): if CORE.is_esp32: socket.require_wake_loop_threadsafe() # Re-enable ESP-IDF's mqtt component (excluded by default to save compile time) - include_builtin_idf_component("mqtt") + # IDF 6.0 moved esp-mqtt to an external component + if idf_version() >= cv.Version(6, 0, 0): + add_idf_component(name="espressif/mqtt", ref="1.0.0") + else: + include_builtin_idf_component("mqtt") cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index bb94de7e052..1e2d452919c 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -37,5 +37,9 @@ dependencies: version: 0.3.2 rules: - if: "target in [esp32, esp32s2, esp32s3, esp32c6, esp32p4]" + espressif/mqtt: + version: "1.0.0" + rules: + - if: "idf_version >=6.0.0" esp32async/asynctcp: version: 3.4.91 From 7f418d969e9bd3de284123fcbae113e5c36a09d6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:57:52 -0400 Subject: [PATCH 090/279] [multiple] Fix implicit int-to-gpio_num_t conversions for GCC 15 (#14830) --- esphome/components/ledc/ledc_output.cpp | 3 ++- esphome/components/mipi_rgb/mipi_rgb.cpp | 17 +++++++++-------- .../pulse_counter/pulse_counter_sensor.cpp | 5 +++-- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 13 +++++++------ esphome/components/st7701s/st7701s.cpp | 13 +++++++------ 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index a3d1e4d3922..d2f2d72acb9 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -3,6 +3,7 @@ #ifdef USE_ESP32 +#include #include #include #include @@ -189,7 +190,7 @@ void LEDCOutput::setup() { this->phase_angle_, hpoint); ledc_channel_config_t chan_conf{}; - chan_conf.gpio_num = this->pin_->get_pin(); + chan_conf.gpio_num = static_cast(this->pin_->get_pin()); chan_conf.speed_mode = speed_mode; chan_conf.channel = chan_num; chan_conf.intr_type = LEDC_INTR_DISABLE; diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index ae7c7958464..824ff6afe79 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -4,7 +4,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esp_lcd_panel_rgb.h" +#include +#include #include namespace esphome { @@ -153,18 +154,18 @@ void MipiRgb::common_setup_() { config.clk_src = LCD_CLK_SRC_PLL160M; size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); for (size_t i = 0; i != data_pin_count; i++) { - config.data_gpio_nums[i] = this->data_pins_[i]->get_pin(); + config.data_gpio_nums[i] = static_cast(this->data_pins_[i]->get_pin()); } config.data_width = data_pin_count; - config.disp_gpio_num = -1; - config.hsync_gpio_num = this->hsync_pin_->get_pin(); - config.vsync_gpio_num = this->vsync_pin_->get_pin(); + config.disp_gpio_num = GPIO_NUM_NC; + config.hsync_gpio_num = static_cast(this->hsync_pin_->get_pin()); + config.vsync_gpio_num = static_cast(this->vsync_pin_->get_pin()); if (this->de_pin_) { - config.de_gpio_num = this->de_pin_->get_pin(); + config.de_gpio_num = static_cast(this->de_pin_->get_pin()); } else { - config.de_gpio_num = -1; + config.de_gpio_num = GPIO_NUM_NC; } - config.pclk_gpio_num = this->pclk_pin_->get_pin(); + config.pclk_gpio_num = static_cast(this->pclk_pin_->get_pin()); esp_err_t err = esp_lcd_new_rgb_panel(&config, &this->handle_); if (err == ESP_OK) err = esp_lcd_panel_reset(this->handle_); diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp index ec00bd024e0..5d73bef7da0 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.cpp +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -2,6 +2,7 @@ #include "esphome/core/log.h" #ifdef HAS_PCNT +#include #include #include #endif @@ -76,8 +77,8 @@ bool HwPulseCounterStorage::pulse_counter_setup(InternalGPIOPin *pin) { } pcnt_chan_config_t chan_config = { - .edge_gpio_num = this->pin->get_pin(), - .level_gpio_num = -1, + .edge_gpio_num = static_cast(this->pin->get_pin()), + .level_gpio_num = GPIO_NUM_NC, }; error = pcnt_new_channel(this->pcnt_unit, &chan_config, &this->pcnt_channel); if (error != ESP_OK) { diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 363f4b63b8e..d29f6a0bcb9 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -2,6 +2,7 @@ #include "rpi_dpi_rgb.h" #include "esphome/core/gpio.h" #include "esphome/core/log.h" +#include namespace esphome { namespace rpi_dpi_rgb { @@ -25,14 +26,14 @@ void RpiDpiRgb::setup() { config.clk_src = LCD_CLK_SRC_PLL160M; size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); for (size_t i = 0; i != data_pin_count; i++) { - config.data_gpio_nums[i] = this->data_pins_[i]->get_pin(); + config.data_gpio_nums[i] = static_cast(this->data_pins_[i]->get_pin()); } config.data_width = data_pin_count; - config.disp_gpio_num = -1; - config.hsync_gpio_num = this->hsync_pin_->get_pin(); - config.vsync_gpio_num = this->vsync_pin_->get_pin(); - config.de_gpio_num = this->de_pin_->get_pin(); - config.pclk_gpio_num = this->pclk_pin_->get_pin(); + config.disp_gpio_num = GPIO_NUM_NC; + config.hsync_gpio_num = static_cast(this->hsync_pin_->get_pin()); + config.vsync_gpio_num = static_cast(this->vsync_pin_->get_pin()); + config.de_gpio_num = static_cast(this->de_pin_->get_pin()); + config.pclk_gpio_num = static_cast(this->pclk_pin_->get_pin()); esp_err_t err = esp_lcd_new_rgb_panel(&config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "lcd_new_rgb_panel failed: %s", esp_err_to_name(err)); diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index ecce4eb4b27..701b6dd79ea 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -2,6 +2,7 @@ #include "st7701s.h" #include "esphome/core/gpio.h" #include "esphome/core/log.h" +#include namespace esphome { namespace st7701s { @@ -27,14 +28,14 @@ void ST7701S::setup() { config.clk_src = LCD_CLK_SRC_PLL160M; size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); for (size_t i = 0; i != data_pin_count; i++) { - config.data_gpio_nums[i] = this->data_pins_[i]->get_pin(); + config.data_gpio_nums[i] = static_cast(this->data_pins_[i]->get_pin()); } config.data_width = data_pin_count; - config.disp_gpio_num = -1; - config.hsync_gpio_num = this->hsync_pin_->get_pin(); - config.vsync_gpio_num = this->vsync_pin_->get_pin(); - config.de_gpio_num = this->de_pin_->get_pin(); - config.pclk_gpio_num = this->pclk_pin_->get_pin(); + config.disp_gpio_num = GPIO_NUM_NC; + config.hsync_gpio_num = static_cast(this->hsync_pin_->get_pin()); + config.vsync_gpio_num = static_cast(this->vsync_pin_->get_pin()); + config.de_gpio_num = static_cast(this->de_pin_->get_pin()); + config.pclk_gpio_num = static_cast(this->pclk_pin_->get_pin()); esp_err_t err = esp_lcd_new_rgb_panel(&config, &this->handle_); if (err != ESP_OK) { esph_log_e(TAG, "lcd_new_rgb_panel failed: %s", esp_err_to_name(err)); From 18a082de30abe7e3e0a525fac10dcf67fb3ef375 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:58:01 -0400 Subject: [PATCH 091/279] [ci] Support URL and version extras in generate-esp32-boards.py (#14828) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- script/generate-esp32-boards.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/script/generate-esp32-boards.py b/script/generate-esp32-boards.py index ab4a38ced5b..9fa0f652efb 100755 --- a/script/generate-esp32-boards.py +++ b/script/generate-esp32-boards.py @@ -7,17 +7,26 @@ import subprocess import sys import tempfile +from esphome import config_validation as cv from esphome.components.esp32 import PLATFORM_VERSION_LOOKUP from esphome.helpers import write_file_if_changed ver = PLATFORM_VERSION_LOOKUP["recommended"] -version_str = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}" root = Path(__file__).parent.parent boards_file_path = root / "esphome" / "components" / "esp32" / "boards.py" def get_boards(): with tempfile.TemporaryDirectory() as tempdir: + if isinstance(ver, cv.Version): + branch = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}" + if ver.extra: + branch += f"-{ver.extra}" + repo = "https://github.com/pioarduino/platform-espressif32" + else: + # URL format: "https://github.com/user/repo.git#branch" + url = str(ver) + repo, branch = url.rsplit("#", 1) if "#" in url else (url, "main") subprocess.run( [ "git", @@ -28,8 +37,8 @@ def get_boards(): "--depth", "1", "--branch", - f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}", - "https://github.com/pioarduino/platform-espressif32", + branch, + repo, tempdir, ], check=True, From 33f9ad9cee4e712d14721966318f99d8d0f63238 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:58:12 -0400 Subject: [PATCH 092/279] [esp32] Support non-numeric version extras in IDF version string (#14826) --- esphome/components/esp32/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index eaa9aa163d9..18178c83ffc 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -612,10 +612,12 @@ def _format_framework_espidf_version( ext = "tar.xz" else: ext = "zip" - # Build version string with dot-separated extra (e.g., "5.5.3.1" not "5.5.3-1") + # Build version string with extra separator based on type: + # numeric extra uses dot (e.g., "5.5.3.1"), string extra uses dash (e.g., "6.0.0-rc1") ver_str = f"{ver.major}.{ver.minor}.{ver.patch}" if ver.extra: - ver_str += f".{ver.extra}" + sep = "." if str(ver.extra).isdigit() else "-" + ver_str += f"{sep}{ver.extra}" if release: return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{ver_str}.{release}/esp-idf-v{ver_str}.{ext}" return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{ver_str}/esp-idf-v{ver_str}.{ext}" From 4219d6d3673d8a1e4d8f36ac6ce45b162c898292 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:19 -1000 Subject: [PATCH 093/279] Bump tornado from 6.5.4 to 6.5.5 (#14704) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3da2d52b44b..e634bcb1046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.4 +tornado==6.5.5 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From 2627490a11952d55ef282c5b6b57522e6467123e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:30:44 -0400 Subject: [PATCH 094/279] [esp32_hosted] Bump esp_hosted to 2.12.1 (#14708) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6d49053d6d5..a51ae2cd666 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -105,7 +105,7 @@ async def to_code(config): if framework_ver >= cv.Version(5, 5, 0): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index acd7f7a4798..f7fd3e67bc3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.0 + version: 2.12.1 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 064bd13ebb6ba91576c7e6c78c23a2986ad5e5d5 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 19:56:25 -0500 Subject: [PATCH 095/279] [ethernet] ESP32-P4 Ethernet compilation fix (#14714) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/ethernet/ethernet_component.cpp | 18 +----------------- .../components/ethernet/ethernet_component.h | 2 ++ esphome/components/ethernet/ethernet_helpers.c | 8 ++++++++ .../components/ethernet/test.esp32-p4-idf.yaml | 1 + 4 files changed, 12 insertions(+), 17 deletions(-) create mode 100644 esphome/components/ethernet/ethernet_helpers.c create mode 100644 tests/components/ethernet/test.esp32-p4-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index d6b0d40cd95..e0788e11498 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -21,22 +21,6 @@ namespace esphome::ethernet { -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) -// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 -#ifdef USE_ESP32_VARIANT_ESP32P4 -#undef ETH_ESP32_EMAC_DEFAULT_CONFIG -#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ - { \ - .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ - .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ - .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ - .emac_dataif_gpio = \ - {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ - .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ - } -#endif -#endif - static const char *const TAG = "ethernet"; // PHY register size for hex logging @@ -162,7 +146,7 @@ void EthernetComponent::setup() { phy_config.phy_addr = this->phy_addr_; phy_config.reset_gpio_num = this->power_pin_; - eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index d9f05be9de0..c464e20b843 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -15,6 +15,8 @@ #include "esp_mac.h" #include "esp_idf_version.h" +extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); + namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c new file mode 100644 index 00000000000..96faccad24c --- /dev/null +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -0,0 +1,8 @@ +#include "esp_eth_mac_esp.h" + +// ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers +// which are valid in C but not in C++. This wrapper allows C++ code to get +// the default config without replicating the macro's contents. +eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { + return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); +} diff --git a/tests/components/ethernet/test.esp32-p4-idf.yaml b/tests/components/ethernet/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e52329d7ea2 --- /dev/null +++ b/tests/components/ethernet/test.esp32-p4-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ip101.yaml From 2b0c471ed7b08e422fe48fd7153c22cfd8f1a23a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 15:00:20 -1000 Subject: [PATCH 096/279] [esp32] Add crash handler to capture and report backtrace across reboots (#14709) --- esphome/components/api/api_connection.h | 6 + esphome/components/api/client.py | 21 ++ esphome/components/esp32/__init__.py | 5 + esphome/components/esp32/core.cpp | 6 + esphome/components/esp32/crash_handler.cpp | 355 +++++++++++++++++++++ esphome/components/esp32/crash_handler.h | 18 ++ esphome/components/logger/logger_esp32.cpp | 4 + esphome/core/defines.h | 1 + esphome/platformio_api.py | 7 + tests/unit_tests/test_platformio_api.py | 28 ++ 10 files changed, 451 insertions(+) create mode 100644 esphome/components/esp32/crash_handler.cpp create mode 100644 esphome/components/esp32/crash_handler.h diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3356511684f..60cc3e91b11 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -14,6 +14,9 @@ #include "api_server.h" #include "esphome/core/application.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_CRASH_HANDLER +#include "esphome/components/esp32/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -235,6 +238,9 @@ class APIConnection final : public APIServerConnectionBase { this->flags_.log_subscription = msg.level; if (msg.dump_config) App.schedule_dump_config(); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; } diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd5..0e71ad8fcbf 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime +import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard + backtrace_state = False + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = getattr(module, "process_stacktrace") + except (AttributeError, ImportError): + pass def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" + nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: ) for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + for raw_line in text.splitlines(): + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace( + config, raw_line, backtrace_state + ) + else: + backtrace_state = process_stacktrace( + config, raw_line, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 52e70501dcf..475de6aa3e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1442,6 +1442,11 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") + # Arduino already wraps esp_panic_handler for its own backtrace handler, + # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 46c000562e1..cba25bca2b2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -36,6 +37,11 @@ void arch_restart() { } void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp new file mode 100644 index 00000000000..ecf30d78781 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,355 @@ +#ifdef USE_ESP32 + +#include "esphome/core/defines.h" +#ifdef USE_ESP32_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#include +#include +#elif CONFIG_IDF_TARGET_ARCH_RISCV +#include +#endif + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +static constexpr size_t MAX_BACKTRACE = 16; + +// Check if an address looks like code (flash-mapped or IRAM). +// Must be safe to call from panic context (no flash access needed). +static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { + return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH); +} + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Check if a code address is a real return address by verifying the preceding +// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during +// panic) so flash cache is available and both IRAM and IROM are safely readable. +static inline bool is_return_addr(uint32_t addr) { + if (!is_code_addr(addr) || addr < 4) + return false; + // A return address on the stack points to the instruction after a call. + // Check for 4-byte JAL/JALR call instruction before this address. + // Use memcpy for alignment safety — RISC-V C extension means code addresses + // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. + uint32_t inst; + memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); + // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd + uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode + uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7) + // Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7) + if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80) + return true; + // Check for 2-byte compressed c.jalr before this address (C extension). + // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 + if (addr >= 2) { + uint16_t c_inst = *(uint16_t *) (addr - 2); + if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) + return true; + } + return false; +} +#endif + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset but contains garbage after power cycle. +// Validated by magic marker. Static linkage since it's only used within this file. +// Version field is first so future firmware can always identify the struct layout. +// Magic is second to validate the data. Remaining fields can change between versions. +// Version is uint32_t because it would be padded to 4 bytes anyway before the next +// uint32_t field, so we use the full width rather than wasting 3 bytes of padding. +static constexpr uint32_t CRASH_DATA_VERSION = 1; +struct RawCrashData { + uint32_t version; + uint32_t magic; + uint32_t pc; + uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) + uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) + uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) +}; +static RawCrashData __attribute__((section(".noinit"))) +s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +void crash_handler_read_and_clear() { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { + s_crash_data_valid = true; + // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data + if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count) + s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count; + if (s_raw_crash_data.exception > 4) // panic_exception_t max value + s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT + if (s_raw_crash_data.pseudo_excause > 1) + s_raw_crash_data.pseudo_excause = 0; + } + // Clear magic regardless so we don't re-report on next normal reboot + s_raw_crash_data.magic = 0; +} + +bool crash_handler_has_data() { return s_crash_data_valid; } + +// Look up the exception cause as a human-readable string. +// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays +// not exposed via any public API. +static const char *get_exception_reason() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + if (s_raw_crash_data.pseudo_excause) { + // SoC-level panic: watchdog, cache error, etc. + // Keep in sync with ESP-IDF's PANIC_RSN_* defines + static const char *const PSEUDO_REASON[] = { + "Unknown reason", // 0 + "Unhandled debug exception", // 1 + "Double exception", // 2 + "Unhandled kernel exception", // 3 + "Coprocessor exception", // 4 + "Interrupt wdt timeout on CPU0", // 5 + "Interrupt wdt timeout on CPU1", // 6 + "Cache error", // 7 + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0])) + return PSEUDO_REASON[cause]; + return PSEUDO_REASON[0]; + } + // Real Xtensa exception + static const char *const REASON[] = { + "IllegalInstruction", + "Syscall", + "InstructionFetchError", + "LoadStoreError", + "Level1Interrupt", + "Alloca", + "IntegerDivideByZero", + "PCValue", + "Privileged", + "LoadStoreAlignment", + nullptr, + nullptr, + "InstrPDAddrError", + "LoadStorePIFDataError", + "InstrPIFAddrError", + "LoadStorePIFAddrError", + "InstTLBMiss", + "InstTLBMultiHit", + "InstFetchPrivilege", + nullptr, + "InstrFetchProhibited", + nullptr, + nullptr, + nullptr, + "LoadStoreTLBMiss", + "LoadStoreTLBMultihit", + "LoadStorePrivilege", + nullptr, + "LoadProhibited", + "StoreProhibited", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // For SoC-level panics (watchdog, cache error), mcause holds IDF-internal + // interrupt numbers, not standard RISC-V cause codes. The exception type + // field already identifies these, so just return null to use the type name. + if (s_raw_crash_data.pseudo_excause) + return nullptr; + static const char *const REASON[] = { + "Instruction address misaligned", + "Instruction access fault", + "Illegal instruction", + "Breakpoint", + "Load address misaligned", + "Load access fault", + "Store address misaligned", + "Store access fault", + "Environment call from U-mode", + "Environment call from S-mode", + nullptr, + "Environment call from M-mode", + "Instruction page fault", + "Load page fault", + nullptr, + "Store page fault", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#endif + return "Unknown"; +} + +// Exception type names matching panic_exception_t enum +static const char *get_exception_type() { + static const char *const TYPES[] = { + "Debug exception", // PANIC_EXCEPTION_DEBUG + "Interrupt wdt", // PANIC_EXCEPTION_IWDT + "Task wdt", // PANIC_EXCEPTION_TWDT + "Abort", // PANIC_EXCEPTION_ABORT + "Fault", // PANIC_EXCEPTION_FAULT + }; + uint8_t exc = s_raw_crash_data.exception; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; + return "Unknown"; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!s_crash_data_valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + const char *reason = get_exception_reason(); + if (reason != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + } else { + ESP_LOGE(TAG, " Reason: %s", get_exception_type()); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + uint8_t bt_num = 0; + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif +#if CONFIG_IDF_TARGET_ARCH_RISCV + const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[256]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::esp32 + +// --- Panic handler wrapper --- +// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data +// into NOINIT memory before the normal panic handler runs. +// +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism +extern void __real_esp_panic_handler(panic_info_t *info); + +void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { + // Save the faulting PC and exception info + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; + s_raw_crash_data.exception = (uint8_t) info->exception; + s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + +#if CONFIG_IDF_TARGET_ARCH_XTENSA + // Xtensa: walk the backtrace using the public API + if (info->frame != nullptr) { + auto *xt_frame = (XtExcFrame *) info->frame; + s_raw_crash_data.cause = xt_frame->exccause; + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) xt_frame->pc, + .sp = (uint32_t) xt_frame->a1, + .next_pc = (uint32_t) xt_frame->a0, + .exc_frame = xt_frame, + }; + + uint8_t count = 0; + // First frame PC + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + s_raw_crash_data.backtrace[count++] = first_pc; + } + // Walk remaining frames + while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) { + break; + } + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + s_raw_crash_data.backtrace[count++] = pc; + } + } + s_raw_crash_data.backtrace_count = count; + } + +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // RISC-V: capture MEPC + RA, then scan stack for code addresses + if (info->frame != nullptr) { + auto *rv_frame = (RvExcFrame *) info->frame; + s_raw_crash_data.cause = rv_frame->mcause; + uint8_t count = 0; + + // Save MEPC (fault PC) and RA (return address) + if (is_code_addr(rv_frame->mepc)) { + s_raw_crash_data.backtrace[count++] = rv_frame->mepc; + } + if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { + s_raw_crash_data.backtrace[count++] = rv_frame->ra; + } + + // Track how many entries came from registers (MEPC/RA) so we can + // skip return-address validation for them at log time. + s_raw_crash_data.reg_frame_count = count; + + // Scan stack for code addresses — captures broadly during panic, + // filtered by is_return_addr() at log time when flash is accessible. + auto *scan_start = (uint32_t *) rv_frame->sp; + for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { + s_raw_crash_data.backtrace[count++] = val; + } + } + s_raw_crash_data.backtrace_count = count; + } +#endif + + // Write version and magic last — ensures all data is written before we mark it valid + s_raw_crash_data.version = CRASH_DATA_VERSION; + s_raw_crash_data.magic = CRASH_MAGIC; + + // Call the real panic handler (prints to UART, does core dump, reboots, etc.) + __real_esp_panic_handler(info); +} + +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +} // extern "C" + +#endif // USE_ESP32_CRASH_HANDLER +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 00000000000..97a4d4e1162 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP32_CRASH_HANDLER + +namespace esphome::esp32 { + +/// Read crash data from NOINIT memory and clear the magic marker. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::esp32 + +#endif // USE_ESP32_CRASH_HANDLER diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4fa..f5bf7822899 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "logger.h" +#include "esphome/components/esp32/crash_handler.h" #include #include @@ -117,6 +118,9 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cec77fe2e27..a33f10cb9c0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -195,6 +195,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESP32_CRASH_HANDLER #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_OTA_ROLLBACK diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 5d4065207f0..cb080b2a953 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -340,6 +340,8 @@ STACKTRACE_ESP32_BACKTRACE_RE = re.compile( r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" ) STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") @@ -371,6 +373,11 @@ def process_stacktrace(config, line, backtrace_state): ) _decode_pc(config, match.group(1)) + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # ESP32 single-line backtrace match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) if match is not None: diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 16861442777..e1b3908c249 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -673,6 +673,34 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = platformio_api.process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt0, False) + mock_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt1, False) + mock_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False + + def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {}) From 7cec2d3029ae4d2f873633c060b49ade4f77eadd Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 23:48:27 -0500 Subject: [PATCH 097/279] [ethernet] ESP32-S3 Ethernet compilation fix (#14717) --- esphome/components/ethernet/ethernet_component.h | 3 +++ esphome/components/ethernet/ethernet_helpers.c | 2 ++ tests/components/ethernet/common-w5500.yaml | 4 ++-- tests/components/ethernet/test.esp32-s3-idf.yaml | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tests/components/ethernet/test.esp32-s3-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index c464e20b843..f7a0996fb74 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -11,11 +11,14 @@ #include "esp_eth.h" #include "esp_eth_mac.h" +#include "esp_eth_mac_esp.h" #include "esp_netif.h" #include "esp_mac.h" #include "esp_idf_version.h" +#if CONFIG_ETH_USE_ESP32_EMAC extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); +#endif namespace esphome::ethernet { diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c index 96faccad24c..963db3ff1c4 100644 --- a/esphome/components/ethernet/ethernet_helpers.c +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -3,6 +3,8 @@ // ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers // which are valid in C but not in C++. This wrapper allows C++ code to get // the default config without replicating the macro's contents. +#if CONFIG_ETH_USE_ESP32_EMAC eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); } +#endif diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index 1f8b8650dd0..bf3f6f3f0c4 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -2,10 +2,10 @@ ethernet: type: W5500 clk_pin: 19 mosi_pin: 21 - miso_pin: 23 + miso_pin: 17 cs_pin: 18 interrupt_pin: 36 - reset_pin: 22 + reset_pin: 12 clock_speed: 10Mhz manual_ip: static_ip: 192.168.178.56 diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..36f1b5365f1 --- /dev/null +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -0,0 +1 @@ +<<: !include common-w5500.yaml From e8f51fec889ada351d35311c2b3ce1ed7239d35e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 19:23:01 -1000 Subject: [PATCH 098/279] [rp2040] Fix crash handler design flaws (#14716) --- esphome/components/api/api_connection.h | 6 ++++++ esphome/components/logger/logger_rp2040.cpp | 5 +++++ esphome/components/rp2040/__init__.py | 1 + esphome/components/rp2040/core.cpp | 6 +++++- esphome/components/rp2040/crash_handler.cpp | 23 ++++++++++++++++----- esphome/components/rp2040/crash_handler.h | 8 ++++++- esphome/core/defines.h | 1 + 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 60cc3e91b11..68f698d1902 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -17,6 +17,9 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -240,6 +243,9 @@ class APIConnection final : public APIServerConnectionBase { App.schedule_dump_config(); #ifdef USE_ESP32_CRASH_HANDLER esp32::crash_handler_log(); +#endif +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_log(); #endif } #ifdef USE_API_HOMEASSISTANT_SERVICES diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f76b823a8f7..b7225c2a258 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,6 +1,9 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,7 +29,9 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index b15811241ca..276187b273c 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -212,6 +212,7 @@ async def to_code(config): ) cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define("USE_RP2040_CRASH_HANDLER") def add_pio_file(component: str, key: str, data: str): diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 5e5a96c78b1..7079cbca155 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,8 +1,10 @@ #ifdef USE_RP2040 #include "core.h" -#include "crash_handler.h" #include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -25,7 +27,9 @@ void arch_restart() { } void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_read_and_clear(); +#endif #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 6ab46da4449..1f579c2d18e 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -1,5 +1,8 @@ #ifdef USE_RP2040 +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER + #include "crash_handler.h" #include "esphome/core/log.h" @@ -13,13 +16,19 @@ static constexpr uint32_t EF_LR = 5; static constexpr uint32_t EF_PC = 6; -static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD), +// lower 16 bits are the version number. This avoids using a separate scratch +// register for versioning (we only have 8 total). Future firmware reads the +// sentinel to confirm it's crash data, then the version to know the layout. +static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000; +static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION; // We only have 8 scratch registers (32 bytes) that survive watchdog reboot. // Use them for the most important data, then scan the stack for code addresses. // // Scratch register layout: -// [0] = magic (CRASH_MAGIC) +// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version) // [1] = PC (program counter at fault) // [2] = LR (link register from exception frame) // [3] = SP (stack pointer at fault) @@ -57,9 +66,12 @@ static struct { uint8_t backtrace_count; } __attribute__((section(".noinit"))) s_crash_data; +bool crash_handler_has_data() { return s_crash_data.valid; } + void crash_handler_read_and_clear() { s_crash_data.valid = false; - if (watchdog_hw->scratch[0] == CRASH_MAGIC) { + uint32_t magic = watchdog_hw->scratch[0]; + if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { s_crash_data.valid = true; s_crash_data.pc = watchdog_hw->scratch[1]; s_crash_data.lr = watchdog_hw->scratch[2]; @@ -135,7 +147,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame // by a stacking error or corrupted SP, frame may be invalid. Write a minimal // crash marker so we at least know a crash occurred. if (!is_valid_sram_ptr(frame)) { - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = 0; // PC unknown watchdog_hw->scratch[2] = 0; // LR unknown watchdog_hw->scratch[3] = reinterpret_cast(frame); // Record the bad SP for diagnosis @@ -157,7 +169,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame uint32_t pre_fault_sp = reinterpret_cast(post_frame); // Write key registers - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = frame[EF_PC]; watchdog_hw->scratch[2] = frame[EF_LR]; watchdog_hw->scratch[3] = pre_fault_sp; @@ -224,4 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h index f10db47c234..78e8ede08c8 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2040/crash_handler.h @@ -2,7 +2,9 @@ #ifdef USE_RP2040 -#include +#include "esphome/core/defines.h" + +#ifdef USE_RP2040_CRASH_HANDLER namespace esphome::rp2040 { @@ -12,6 +14,10 @@ void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + } // namespace esphome::rp2040 +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a33f10cb9c0..073170aafbf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -338,6 +338,7 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) #define USE_LOOP_PRIORITY +#define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC From 6002badb3c35b20475a76cef3781f13967c1260e Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Thu, 12 Mar 2026 02:00:26 -0600 Subject: [PATCH 099/279] [modbus] Fix buffer overflow in modbus (#14719) Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 12 ++--- tests/components/modbus/modbus_test.cpp | 59 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/components/modbus/modbus_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 82672217c56..7a61868e6e9 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -125,13 +125,17 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) { // Byte 0: modbus address (match all) if (at == 0) return true; - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; + // Byte 1: function code + if (at == 1) + return true; // Byte 2: Size (with modbus rtu function code 4/3) // See also https://en.wikipedia.org/wiki/Modbus if (at == 2) return true; + uint8_t address = raw[0]; + uint8_t function_code = raw[1]; + uint8_t data_len = raw[2]; uint8_t data_offset = 3; @@ -146,10 +150,6 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) { // chance that this is a complete message ... admittedly there is a small chance is // isn't but that is quite small given the purpose of the CRC in the first place - // Fewer than 2 bytes can't calc CRC - if (at < 2) - return true; - data_len = at - 2; data_offset = 1; diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp new file mode 100644 index 00000000000..afe5ced082b --- /dev/null +++ b/tests/components/modbus/modbus_test.cpp @@ -0,0 +1,59 @@ +#include +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/helpers.h" + +namespace esphome::modbus { + +// Exposes protected methods for testing. +class TestModbus : public Modbus { + public: + bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } + void test_clear_rx_buffer() { this->rx_buffer_.clear(); } + void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } +}; + +class MockDevice : public ModbusDevice { + public: + void on_modbus_data(const std::vector &data) override { this->data_received = true; } + bool data_received{false}; +}; + +TEST(ModbusTest, TwoByteRegressionTest) { + TestModbus modbus; + modbus.set_role(ModbusRole::CLIENT); + // First byte (at=0) + EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); + // Second byte (at=1) + // This used to reach raw[2] because it skipped the if(at==2) check, causing a + // buffer overflow. + EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); +} + +TEST(ModbusTest, TestValidFrame) { + TestModbus modbus; + modbus.set_role(ModbusRole::CLIENT); + + MockDevice device; + device.set_parent(&modbus); + device.set_address(0x01); + modbus.register_device(&device); + modbus.set_waiting(0x01); + + // Address 1, Function 3, Length 2, Data 0x1234 + uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; + uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); + + std::vector frame; + for (uint8_t b : frame_data) + frame.push_back(b); + frame.push_back(crc & 0xFF); + frame.push_back((crc >> 8) & 0xFF); + + for (size_t i = 0; i < frame.size(); i++) { + bool result = modbus.test_parse_modbus_byte(frame[i]); + EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; + } + EXPECT_TRUE(device.data_received); +} + +} // namespace esphome::modbus From 4b1c4ba5c0561cba93d979aa8fb76da0bafff301 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 12 Mar 2026 03:16:02 -0500 Subject: [PATCH 100/279] [ledc] Fix high-pressure crash & recovery (#14720) --- esphome/components/ledc/ledc_output.cpp | 53 +++++++++++++++++++++++-- esphome/components/ledc/ledc_output.h | 8 ++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 763de851da3..592fc7bd0c1 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -5,6 +5,10 @@ #include #include +#include +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) +#include +#endif #define CLOCK_FREQUENCY 80e6f @@ -16,10 +20,10 @@ static const uint8_t SETUP_ATTEMPT_COUNT_MAX = 5; -namespace esphome { -namespace ledc { +namespace esphome::ledc { static const char *const TAG = "ledc.output"; +static bool ledc_peripheral_reset_done = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const int MAX_RES_BITS = LEDC_TIMER_BIT_MAX - 1; #if SOC_LEDC_SUPPORT_HS_MODE @@ -32,6 +36,28 @@ inline ledc_mode_t get_speed_mode(uint8_t channel) { return channel < 8 ? LEDC_H inline ledc_mode_t get_speed_mode(uint8_t) { return LEDC_LOW_SPEED_MODE; } #endif +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) +// Classic ESP32 (currently the only target without SOC_LEDC_SUPPORT_FADE_STOP) can block in +// ledc_ll_set_duty_start() while duty_start is set. We check the same conf1.duty_start bit here +// to defer updates and avoid entering IDF's unbounded wait loop. +// +// This intentionally depends on the classic ESP32 LEDC register layout used by IDF's own LL HAL. +// If another target without SOC_LEDC_SUPPORT_FADE_STOP is introduced, revisit this helper. +static_assert( +#if defined(CONFIG_IDF_TARGET_ESP32) + true, +#else + false, +#endif + "LEDC duty_start pending check assumes classic ESP32 register layout; " + "re-evaluate for this target"); + +static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { + auto *hw = LEDC_LL_GET_HW(); + return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; +} +#endif + float ledc_max_frequency_for_bit_depth(uint8_t bit_depth) { return static_cast(CLOCK_FREQUENCY) / static_cast(1 << bit_depth); } @@ -105,21 +131,40 @@ void LEDCOutput::write_state(float state) { const uint32_t max_duty = (uint32_t(1) << this->bit_depth_) - 1; const float duty_rounded = roundf(state * max_duty); auto duty = static_cast(duty_rounded); + if (duty == this->last_duty_) { + return; + } + ESP_LOGV(TAG, "Setting duty: %" PRIu32 " on channel %u", duty, this->channel_); auto speed_mode = get_speed_mode(this->channel_); auto chan_num = static_cast(this->channel_ % 8); int hpoint = ledc_angle_to_htop(this->phase_angle_, this->bit_depth_); if (duty == max_duty) { ledc_stop(speed_mode, chan_num, 1); + this->last_duty_ = duty; } else if (duty == 0) { ledc_stop(speed_mode, chan_num, 0); + this->last_duty_ = duty; } else { +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) + if (ledc_duty_update_pending(speed_mode, chan_num)) { + ESP_LOGV(TAG, "Skipping LEDC duty update on channel %u while previous duty_start is still set", this->channel_); + return; + } +#endif ledc_set_duty_with_hpoint(speed_mode, chan_num, duty, hpoint); ledc_update_duty(speed_mode, chan_num); + this->last_duty_ = duty; } } void LEDCOutput::setup() { + if (!ledc_peripheral_reset_done) { + ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); + periph_module_reset(PERIPH_LEDC_MODULE); + ledc_peripheral_reset_done = true; + } + auto speed_mode = get_speed_mode(this->channel_); auto timer_num = static_cast((this->channel_ % 8) / 2); auto chan_num = static_cast(this->channel_ % 8); @@ -207,12 +252,12 @@ void LEDCOutput::update_frequency(float frequency) { this->status_clear_error(); // re-apply duty + this->last_duty_ = UINT32_MAX; this->write_state(this->duty_); } uint8_t next_ledc_channel = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b24e3cfdb23..bf5cdb93055 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -4,11 +4,11 @@ #include "esphome/core/hal.h" #include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" +#include #ifdef USE_ESP32 -namespace esphome { -namespace ledc { +namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; @@ -39,6 +39,7 @@ class LEDCOutput : public output::FloatOutput, public Component { float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; + uint32_t last_duty_{UINT32_MAX}; bool initialized_ = false; }; @@ -56,7 +57,6 @@ template class SetFrequencyAction : public Action { LEDCOutput *parent_; }; -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif From df2ddc47ec2baae7da48a6f14831c429c8f292af Mon Sep 17 00:00:00 2001 From: Brian Kaufman Date: Thu, 12 Mar 2026 02:07:26 -0700 Subject: [PATCH 101/279] [web_server] use DETAIL_ALL in update_all_json_generator (#14711) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5590e67b822..40830196433 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2181,7 +2181,7 @@ json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *we } json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE); + return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL); } json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson From 440734dadf0e4c60accb4a5d6b79eaf6a9c9c60d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 12 Mar 2026 07:56:01 -0500 Subject: [PATCH 102/279] [audio] Bump microOpus to v0.3.5 (#14727) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d95fcf66d7b..b28c2ed3d8c 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -214,4 +214,4 @@ async def to_code(config): cg.add_define("USE_AUDIO_MP3_SUPPORT") if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") - add_idf_component(name="esphome/micro-opus", ref="0.3.4") + add_idf_component(name="esphome/micro-opus", ref="0.3.5") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f7fd3e67bc3..df651ae15dd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/esp-audio-libs: version: 2.0.3 esphome/micro-opus: - version: 0.3.4 + version: 0.3.5 espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: From da130c900f82326080c1b790be68c9625912b153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20K=C3=B6nig?= Date: Thu, 12 Mar 2026 17:00:08 +0100 Subject: [PATCH 103/279] [mqtt] Fixed permission denied error for client certificates on Windows (#13525) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/mqtt.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index cbf78bd3f6e..ccacbaea54f 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -2,6 +2,7 @@ import contextlib from datetime import datetime import json import logging +import os import ssl import tempfile import time @@ -109,14 +110,18 @@ def prepare( CONF_CLIENT_CERTIFICATE_KEY ): with ( - tempfile.NamedTemporaryFile(mode="w+") as cert_file, - tempfile.NamedTemporaryFile(mode="w+") as key_file, + tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file, + tempfile.NamedTemporaryFile(mode="w+", delete=False) as key_file, ): - cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) - cert_file.flush() - key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) - key_file.flush() - context.load_cert_chain(cert_file.name, key_file.name) + try: + cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) + key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) + cert_file.close() + key_file.close() + context.load_cert_chain(cert_file.name, key_file.name) + finally: + os.unlink(cert_file.name) + os.unlink(key_file.name) client.tls_set_context(context) try: From 3a838d897fac21b120b8d913141dee194cf87c6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:14:49 -1000 Subject: [PATCH 104/279] [socket] Fix use-after-free in LWIP PCB close/abort path (#14706) --- .../components/socket/lwip_raw_tcp_impl.cpp | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index fd1b8a95542..1e03a4935c2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -138,13 +138,46 @@ static const char *const TAG = "socket.lwip"; #define LWIP_LOG(msg, ...) #endif +// Clear arg, recv, and err callbacks, then abort a connected PCB. +// Only valid for full tcp_pcb (not tcp_pcb_listen). +// Must be called before destroying the object that tcp_arg points to — +// tcp_abort() triggers the err callback synchronously, which would +// otherwise call back into a partially-destroyed object. +// tcp_sent/tcp_poll are not cleared because this implementation +// never registers them. +static void pcb_detach_abort(struct tcp_pcb *pcb) { + tcp_arg(pcb, nullptr); + tcp_recv(pcb, nullptr); + tcp_err(pcb, nullptr); + tcp_abort(pcb); +} + +// Clear arg, recv, and err callbacks, then gracefully close a connected PCB. +// Only valid for full tcp_pcb (not tcp_pcb_listen). +// After tcp_close(), the PCB remains alive during the TCP close handshake +// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP +// would call recv/err on a destroyed socket object, corrupting the heap. +// tcp_sent/tcp_poll are not cleared because this implementation +// never registers them. +// Returns ERR_OK on success; on failure the PCB is aborted instead. +static err_t pcb_detach_close(struct tcp_pcb *pcb) { + tcp_arg(pcb, nullptr); + tcp_recv(pcb, nullptr); + tcp_err(pcb, nullptr); + err_t err = tcp_close(pcb); + if (err != ERR_OK) { + tcp_abort(pcb); + } + return err; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { LWIP_LOCK(); if (this->pcb_ != nullptr) { LWIP_LOG("tcp_abort(%p)", this->pcb_); - tcp_abort(this->pcb_); + pcb_detach_abort(this->pcb_); this->pcb_ = nullptr; } } @@ -222,15 +255,13 @@ int LWIPRawCommon::close() { return -1; } LWIP_LOG("tcp_close(%p)", this->pcb_); - err_t err = tcp_close(this->pcb_); + err_t err = pcb_detach_close(this->pcb_); + this->pcb_ = nullptr; if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - tcp_abort(this->pcb_); - this->pcb_ = nullptr; errno = err == ERR_MEM ? ENOMEM : EIO; return -1; } - this->pcb_ = nullptr; return 0; } @@ -673,13 +704,10 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { LWIPRawListenImpl::~LWIPRawListenImpl() { LWIP_LOCK(); // Abort any queued PCBs that were never accepted by the main loop. - // Clear the error callback first — tcp_abort triggers it, and we don't - // want s_queued_err_fn writing to slots during destruction. for (uint8_t i = 0; i < this->accepted_socket_count_; i++) { auto &entry = this->accepted_pcbs_[i]; if (entry.pcb != nullptr) { - tcp_err(entry.pcb, nullptr); - tcp_abort(entry.pcb); + pcb_detach_abort(entry.pcb); entry.pcb = nullptr; } if (entry.rx_buf != nullptr) { @@ -691,6 +719,10 @@ LWIPRawListenImpl::~LWIPRawListenImpl() { // Listen PCBs must use tcp_close(), not tcp_abort(). // tcp_abandon() asserts pcb->state != LISTEN and would access // fields that don't exist in the smaller tcp_pcb_listen struct. + // Don't use pcb_detach_close() here — tcp_recv()/tcp_err() also access + // fields that only exist in the full tcp_pcb, not tcp_pcb_listen. + // tcp_close() on a listen PCB is synchronous (frees immediately), + // so there are no async callbacks to worry about. // Close here and null pcb_ so the base destructor skips tcp_abort. if (this->pcb_ != nullptr) { tcp_close(this->pcb_); From 1d881ef6f4d4a4ba8575fe5ac1c4bb50c121f767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:04 -1000 Subject: [PATCH 105/279] [socket] Fast path for TCP_NODELAY bypasses lwip_setsockopt overhead (#14693) --- esphome/components/socket/bsd_sockets_impl.h | 11 ++++++++++- esphome/components/socket/lwip_sockets_impl.h | 11 ++++++++++- esphome/core/lwip_fast_select.c | 16 ++++++++++++++++ esphome/core/lwip_fast_select.h | 7 +++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 9ebbe72002b..339a699bc97 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -14,7 +14,7 @@ #endif #ifdef USE_LWIP_FAST_SELECT -struct lwip_sock; +#include "esphome/core/lwip_fast_select.h" #endif namespace esphome::socket { @@ -56,6 +56,15 @@ class BSDSocketImpl { return ::getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) { +#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING) + // Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock, + // bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting). + if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) { + LwIPLock lock; + if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast(optval) != 0)) + return 0; + } +#endif return ::setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return ::listen(this->fd_, backlog); } diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index c5792198635..bfc4da9926a 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -10,7 +10,7 @@ #include "headers.h" #ifdef USE_LWIP_FAST_SELECT -struct lwip_sock; +#include "esphome/core/lwip_fast_select.h" #endif namespace esphome::socket { @@ -52,6 +52,15 @@ class LwIPSocketImpl { return lwip_getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) { +#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING) + // Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock, + // bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting). + if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) { + LwIPLock lock; + if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast(optval) != 0)) + return 0; + } +#endif return lwip_setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return lwip_listen(this->fd_, backlog); } diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index c578a9aae91..a695fa396bc 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -112,6 +112,7 @@ // LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. #include #include +#include // FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not #ifdef USE_ESP32 #include @@ -216,6 +217,21 @@ void esphome_lwip_hook_socket(struct lwip_sock *sock) { sock->conn->callback = esphome_socket_event_callback; } +bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable) { + if (sock == NULL || sock->conn == NULL) + return false; + if (NETCONNTYPE_GROUP(sock->conn->type) != NETCONN_TCP) + return false; + if (sock->conn->pcb.tcp == NULL) + return false; + if (enable) { + tcp_nagle_disable(sock->conn->pcb.tcp); + } else { + tcp_nagle_enable(sock->conn->pcb.tcp); + } + return true; +} + // Wake the main loop from another FreeRTOS task. NOT ISR-safe. void esphome_lwip_wake_main_loop(void) { TaskHandle_t task = s_main_loop_task; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 46c6b711cd2..50706ba9f69 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -66,6 +66,13 @@ void esphome_lwip_wake_main_loop(void); /// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken); +/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. +/// Must be called with the TCPIP core lock held (LwIPLock in C++). +/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, +/// hooks, refcounting) — just a direct pcb->flags bit set/clear. +/// Returns true if successful, false if sock/conn/pcb is NULL or the socket is not TCP. +bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable); + /// Wake the main loop task from any context (ISR, thread, or main loop). /// ESP32-only: uses xPortInIsrContext() to detect ISR context. /// LibreTiny lacks IRAM_ATTR support needed for ISR-safe paths. From 1b7d0f9c0b6745f3610dc84b38dd7236fc5dced7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:21 -1000 Subject: [PATCH 106/279] [esp32_ble_client] Fix disconnect race that causes stuck connections (#14211) Co-authored-by: Claude Opus 4.6 --- .../esp32_ble_client/ble_client_base.cpp | 43 ++++++++++++++++--- .../esp32_ble_client/ble_client_base.h | 17 +++++++- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2f17334c77c..9d6e079d926 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -27,6 +27,7 @@ static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, .uuid = @@ -62,6 +63,15 @@ void BLEClientBase::loop() { // will enable it again when a connection is needed. else if (this->state() == espbt::ClientState::IDLE) { this->disable_loop(); + } else if (this->state() == espbt::ClientState::DISCONNECTING && + (millis() - this->disconnecting_started_) > DISCONNECTING_TIMEOUT) { + ESP_LOGE(TAG, "[%d] [%s] Timeout waiting for CLOSE_EVT after disconnect, forcing IDLE", this->connection_index_, + this->address_str_); + // release_services() must be called before set_idle_() — if we entered DISCONNECTING + // via unconditional_disconnect() (which doesn't call release_services()), and ESP-IDF + // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. + this->release_services(); + this->set_idle_(); } } @@ -101,12 +111,16 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { #endif void BLEClientBase::connect() { - // Prevent duplicate connection attempts + // Prevent duplicate connection attempts or connecting while still disconnecting if (this->state() == espbt::ClientState::CONNECTING || this->state() == espbt::ClientState::CONNECTED || this->state() == espbt::ClientState::ESTABLISHED) { ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state())); return; + } else if (this->state() == espbt::ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] [%s] Cannot connect, still waiting for CLOSE_EVT to complete disconnect", + this->connection_index_, this->address_str_); + return; } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; @@ -174,7 +188,7 @@ void BLEClientBase::unconditional_disconnect() { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { - this->set_state(espbt::ClientState::DISCONNECTING); + this->set_disconnecting_(); } } @@ -220,6 +234,7 @@ void BLEClientBase::log_connection_params_(const char *param_type) { void BLEClientBase::handle_connection_result_(esp_err_t ret) { if (ret) { this->log_gattc_warning_("esp_ble_gattc_open", ret); + // Don't use set_idle_() here — CONNECT_EVT never fired so conn_id_ is still UNSET_CONN_ID. this->set_state(espbt::ClientState::IDLE); } } @@ -311,15 +326,16 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); - this->set_state(espbt::ClientState::IDLE); + // Connection was never established so CLOSE_EVT may not follow + this->set_idle_(); break; } if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have // this->conn_id_ set, we can disconnect it. + // Don't reset conn_id_ here — CLOSE_EVT needs it to match and call set_idle_(). this->unconditional_disconnect(); - this->conn_id_ = UNSET_CONN_ID; break; } // MTU negotiation already started in ESP_GATTC_CONNECT_EVT @@ -363,8 +379,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_, param->disconnect.reason); } + // For active disconnects (esp_ble_gattc_close), CLOSE_EVT arrives before + // DISCONNECT_EVT. If CLOSE_EVT already transitioned us to IDLE, don't go + // backwards to DISCONNECTING — the connection is already fully cleaned up. + if (this->state() == espbt::ClientState::IDLE) { + this->log_event_("DISCONNECT_EVT after CLOSE_EVT, already IDLE"); + break; + } + // For passive disconnects (remote device disconnected or link lost), + // DISCONNECT_EVT arrives first. Don't transition to IDLE yet — wait for + // CLOSE_EVT to ensure the controller has fully freed resources (L2CAP + // channels, ATT resources, HCI connection handle). Transitioning to IDLE + // here would allow reconnection before cleanup is complete, causing the + // controller to reject the new connection (status=133) or crash with + // ASSERT_PARAM in lld_evt.c. this->release_services(); - this->set_state(espbt::ClientState::IDLE); + this->set_disconnecting_(); break; } @@ -387,8 +417,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ return false; this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); - this->set_state(espbt::ClientState::IDLE); - this->conn_id_ = UNSET_CONN_ID; + this->set_idle_(); break; } case ESP_GATTC_SEARCH_RES_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index af4f1b30290..4e0b22cc299 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -113,11 +113,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; esp_bd_addr_t remote_bda_; // 6 bytes - // Group 5: 2-byte types + // Group 5: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 6: 2-byte types uint16_t conn_id_{UNSET_CONN_ID}; uint16_t mtu_{23}; - // Group 6: 1-byte types and small enums + // Group 7: 1-byte types and small enums esp_ble_addr_type_t remote_addr_type_{BLE_ADDR_TYPE_PUBLIC}; espbt::ConnectionType connection_type_{espbt::ConnectionType::V1}; uint8_t connection_index_; @@ -137,6 +140,16 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Transition to IDLE and reset conn_id — call when the connection is fully dead. + void set_idle_() { + this->set_state(espbt::ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + } + /// Transition to DISCONNECTING and start the safety timeout. + void set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(espbt::ClientState::DISCONNECTING); + } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From 33475703da772d24e7a6df3e8a15160c5db53f8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:34 -1000 Subject: [PATCH 107/279] [time] Fix settimeofday() failure on ESP8266 (#14707) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/time/real_time_clock.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 566344fa880..4e623942ac5 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -88,16 +88,16 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { struct timeval timev { .tv_sec = static_cast(epoch), .tv_usec = 0, }; +#ifdef USE_ESP8266 + // ESP8266 settimeofday() requires tz to be nullptr + int ret = settimeofday(&timev, nullptr); +#else struct timezone tz = {0, 0}; int ret = settimeofday(&timev, &tz); - if (ret != 0 && errno == EINVAL) { - // Some ESP8266 frameworks abort when timezone parameter is not NULL - // while ESP32 expects it not to be NULL - ret = settimeofday(&timev, nullptr); - } +#endif if (ret != 0) { - ESP_LOGW(TAG, "setimeofday() failed with code %d", ret); + ESP_LOGW(TAG, "settimeofday() failed with code %d", ret); } #endif auto time = this->now(); From c8cf9b74b104a1965897c16706ee30999d6fe25b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:15:48 -1000 Subject: [PATCH 108/279] [ota][socket] Fix ESP8266/RP2040 OTA timeout by using SO_RCVTIMEO instead of polling (#14675) --- .../components/esphome/ota/ota_esphome.cpp | 46 +++++++++++- esphome/components/socket/headers.h | 2 + .../components/socket/lwip_raw_tcp_impl.cpp | 71 +++++++++++++++++-- esphome/components/socket/lwip_raw_tcp_impl.h | 16 +++-- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index a1cdf59d2b7..d8dbe2dee2d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -18,6 +18,7 @@ #include #include +#include namespace esphome { @@ -238,6 +239,31 @@ void ESPHomeOTAComponent::handle_data_() { /// and reboots on success. /// /// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ. + /// + /// Socket I/O strategy: + /// + /// Before this function, the handshake states use non-blocking I/O: + /// read()/write() return immediately with EWOULDBLOCK if no data + /// loop() retries on next iteration (~16ms), no delay needed + /// + /// This function switches to blocking mode with SO_RCVTIMEO/SO_SNDTIMEO: + /// + /// Path | Wait mechanism | WDT strategy + /// --------------|------------------------|--------------------------- + /// Main read | SO_RCVTIMEO (2s block) | feed_wdt() only, no delay + /// readall_() | SO_RCVTIMEO (2s block) | feed_wdt() + delay(0) + /// writeall_() | SO_SNDTIMEO (2s block) | feed_wdt() + delay(1) + /// + /// readall_() uses delay(0) because SO_RCVTIMEO already waited — just yield. + /// writeall_() uses delay(1) because on raw TCP (ESP8266, RP2040) writes + /// never block (tcp_write returns immediately), so delay(1) prevents spinning. + /// + /// Platform details: + /// BSD sockets (ESP32): setblocking(true) makes read/write block + /// lwip sockets (LT): setblocking(true) makes read/write block + /// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses + /// socket_delay()/socket_wake() in read(); + /// write() always returns immediately ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; @@ -249,6 +275,14 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif + // Set socket timeouts and blocking mode (see strategy table above) + struct timeval tv; + tv.tv_sec = 2; + tv.tv_usec = 0; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + this->client_->setblocking(true); + // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); @@ -299,7 +333,8 @@ void ESPHomeOTAComponent::handle_data_() { ssize_t read = this->client_->read(buf, requested); if (read == -1) { if (this->would_block_(errno)) { - this->yield_and_feed_watchdog_(); + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); continue; } ESP_LOGW(TAG, "Read err %d", errno); @@ -401,7 +436,9 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { } else { at += read; } - this->yield_and_feed_watchdog_(); + // read() already waited via SO_RCVTIMEO, just yield without 1ms stall + App.feed_wdt(); + delay(0); } return true; @@ -422,10 +459,13 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, errno); return false; } + // EWOULDBLOCK: on raw TCP writes never block, delay(1) prevents spinning + this->yield_and_feed_watchdog_(); } else { at += written; + // write() may block up to SO_SNDTIMEO on BSD/lwip sockets, feed WDT + App.feed_wdt(); } - this->yield_and_feed_watchdog_(); } return true; } diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 16e4d23d3ba..0eece6480f6 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -51,6 +51,8 @@ #define SO_REUSEADDR 0x0004 /* Allow local address reuse */ #define SO_KEEPALIVE 0x0008 /* keep connections alive */ #define SO_BROADCAST 0x0020 /* permit to send and to receive broadcast messages (see IP_SOF_BROADCAST option) */ +#define SO_RCVTIMEO 0x1006 /* receive timeout */ +#define SO_SNDTIMEO 0x1005 /* send timeout */ #define SOL_SOCKET 0xfff /* options for socket level */ diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 1e03a4935c2..96328e68c73 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -81,7 +82,9 @@ void socket_delay(uint32_t ms) { s_socket_woke = false; return; } - s_socket_woke = false; + // Don't clear s_socket_woke here — if an IRQ fires between the check above + // and the while loop below, the while condition sees it immediately. Clearing + // here would lose that wake and sleep until the timer fires. s_delay_expired = false; // Set a one-shot timer to wake us after the timeout. // add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error. @@ -99,6 +102,7 @@ void socket_delay(uint32_t ms) { // Cancel timer if we woke early (socket data arrived before timeout) if (!s_delay_expired) cancel_alarm(alarm); + s_socket_woke = false; // consume the wake for next call } // No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP @@ -359,6 +363,18 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o *optlen = 4; return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (*optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + uint32_t ms = this->recv_timeout_cs_ * 10; + auto *tv = reinterpret_cast(optval); + tv->tv_sec = ms / 1000; + tv->tv_usec = (ms % 1000) * 1000; + *optlen = sizeof(struct timeval); + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (*optlen < 4) { errno = EINVAL; @@ -388,6 +404,21 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle // to prevent warnings return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + const auto *tv = reinterpret_cast(optval); + uint32_t ms = tv->tv_sec * 1000 + tv->tv_usec / 1000; + uint32_t cs = (ms + 9) / 10; // round up to nearest centisecond + this->recv_timeout_cs_ = cs > 255 ? 255 : static_cast(cs); + return 0; + } + if (level == SOL_SOCKET && optname == SO_SNDTIMEO) { + // Raw TCP writes are non-blocking (tcp_write), so send timeout is a no-op. + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (optlen != 4) { errno = EINVAL; @@ -518,8 +549,25 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { return ERR_OK; } -ssize_t LWIPRawImpl::read(void *buf, size_t len) { - LWIP_LOCK(); +void LWIPRawImpl::wait_for_data_() { + // Wait for data without holding LWIP_LOCK so recv_fn() can run on RP2040 + // (needs async_context lock). + // + // Loop until data arrives, connection closes, or the full timeout elapses. + // socket_delay() may return early due to other sockets waking the global + // socket_wake() flag, so we re-enter for the remaining time. + uint32_t timeout_ms = this->recv_timeout_cs_ * 10; + uint32_t start = millis(); + while (this->waiting_for_data_()) { + uint32_t elapsed = millis() - start; + if (elapsed >= timeout_ms) + break; + socket_delay(timeout_ms - elapsed); + } +} + +ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { + // Caller must hold LWIP_LOCK. Copies available data from rx_buf_ into buf. if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -578,11 +626,26 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { return read; } +ssize_t LWIPRawImpl::read(void *buf, size_t len) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + + LWIP_LOCK(); + return this->read_locked_(buf, len); +} + ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t ret = 0; for (int i = 0; i < iovcnt; i++) { - ssize_t err = this->read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); + ssize_t err = this->read_locked_(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { if (ret != 0) { // if we already read some don't return an error diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 95931afcf3f..3c27d71062f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -57,6 +57,7 @@ class LWIPRawCommon { // instead use it for determining whether to call lwip_output bool nodelay_ = false; sa_family_t family_ = 0; + uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s) }; /// Connected socket implementation for LWIP raw TCP. @@ -107,11 +108,8 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ECONNRESET; return -1; } - if (blocking) { - // blocking operation not supported - errno = EINVAL; - return -1; - } + // Raw TCP doesn't use a blocking flag directly. Blocking behavior + // is provided by SO_RCVTIMEO which makes read() wait via socket_delay(). return 0; } int loop() { return 0; } @@ -122,6 +120,14 @@ class LWIPRawImpl : public LWIPRawCommon { static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); protected: + // True when the socket could receive data but none has arrived yet. + // Safe to call without LWIP_LOCK — only null-checks pointers and reads a bool, + // all atomic on ARM/Xtensa. A stale value is harmless: the caller either does + // an unnecessary wait (stale true) or skips it (stale false), and the + // authoritative recheck happens under LWIP_LOCK afterward. + bool waiting_for_data_() const { return this->rx_buf_ == nullptr && !this->rx_closed_ && this->pcb_ != nullptr; } + void wait_for_data_(); + ssize_t read_locked_(void *buf, size_t len); ssize_t internal_write_(const void *buf, size_t len); int internal_output_(); From 2ba807efe85c5e1216f793b2b1354576d38a49bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:08 -1000 Subject: [PATCH 109/279] [adc] Fix PICO_VSYS_PIN compile error on RP2350 boards (#14724) --- esphome/components/adc/adc_sensor_rp2040.cpp | 7 +++++++ tests/components/adc/test.rp2040-pico2-ard.yaml | 11 +++++++++++ tests/components/spi/test.rp2040-pico2-ard.yaml | 6 ++++++ .../build_components_base.rp2040-pico2-ard.yaml | 15 +++++++++++++++ .../common/spi/rp2040-pico2-ard.yaml | 12 ++++++++++++ 5 files changed, 51 insertions(+) create mode 100644 tests/components/adc/test.rp2040-pico2-ard.yaml create mode 100644 tests/components/spi/test.rp2040-pico2-ard.yaml create mode 100644 tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml create mode 100644 tests/test_build_components/common/spi/rp2040-pico2-ard.yaml diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8496e0f41e4..a79707e2347 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -8,6 +8,13 @@ #endif // CYW43_USES_VSYS_PIN #include +// PICO_VSYS_PIN is defined in pico-sdk board headers (e.g. boards/pico2.h), +// but the Arduino framework's config_autogen.h includes a generic board header +// that doesn't define it. Provide the standard value (pin 29) as a fallback. +#ifndef PICO_VSYS_PIN +#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage) +#endif + namespace esphome { namespace adc { diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..4cc865bb5d3 --- /dev/null +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -0,0 +1,11 @@ +sensor: + - id: my_sensor + platform: adc + pin: VCC + name: ADC Test sensor + update_interval: "1:01" + unit_of_measurement: "°C" + icon: "mdi:water-percent" + accuracy_decimals: 5 + setup_priority: -100 + force_update: true diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..81a8acafd88 --- /dev/null +++ b/tests/components/spi/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + clk_pin: GPIO2 + mosi_pin: GPIO3 + miso_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..0922a5238e8 --- /dev/null +++ b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestrp2040pico2ard + friendly_name: $component_name + +rp2040: + board: rpipico2 + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..205beb6e1bb --- /dev/null +++ b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml @@ -0,0 +1,12 @@ +# Common SPI configuration for RP2040 Pico 2 (RP2350) Arduino tests + +substitutions: + clk_pin: GPIO18 + mosi_pin: GPIO19 + miso_pin: GPIO16 + +spi: + - id: spi_bus + clk_pin: ${clk_pin} + mosi_pin: ${mosi_pin} + miso_pin: ${miso_pin} From cb4d1d1b5e2f6a354b4b873e444386a4fd4b2924 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:23 -1000 Subject: [PATCH 110/279] [api] Fix undefined behavior in noise handshake with empty rx buffer (#14722) --- esphome/components/api/api_frame_helper_noise.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 3e6ecf9dc30..f945253c89d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -258,10 +258,13 @@ APIError APINoiseFrameHelper::state_action_() { // ignore contents, may be used in future for flags // Resize for: existing prologue + 2 size bytes + frame data size_t old_size = this->prologue_.size(); - this->prologue_.resize(old_size + 2 + this->rx_buf_.size()); - this->prologue_[old_size] = (uint8_t) (this->rx_buf_.size() >> 8); - this->prologue_[old_size + 1] = (uint8_t) this->rx_buf_.size(); - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), this->rx_buf_.size()); + size_t rx_size = this->rx_buf_.size(); + this->prologue_.resize(old_size + 2 + rx_size); + this->prologue_[old_size] = (uint8_t) (rx_size >> 8); + this->prologue_[old_size + 1] = (uint8_t) rx_size; + if (rx_size > 0) { + std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + } state_ = State::SERVER_HELLO; } From 23c7e0f8030d5caa5b07a9447293114639e89472 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:38 -1000 Subject: [PATCH 111/279] [uart] Allow hardware UART with single pin on RP2040 (#14725) --- .../components/uart/uart_component_rp2040.cpp | 37 +++++++++++++++---- tests/components/uart/test.rp2040-ard.yaml | 3 ++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index 858f1a02ddf..6f6f1fb96b7 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -105,15 +105,34 @@ void RP2040UartComponent::setup() { } } + // Determine which hardware UART to use. A pin that is not specified + // should not prevent hardware UART selection — one-way UART is valid. + // When both pins are configured, both must be HW-capable and agree on UART number. + // When only one pin is configured (nullptr other), use that pin's HW UART. + // If a pin is configured but not HW-capable (inverted/invalid), fall back to SerialPIO. + int8_t hw_uart = -1; + const bool tx_configured = (this->tx_pin_ != nullptr); + const bool rx_configured = (this->rx_pin_ != nullptr); + + if (tx_configured && rx_configured) { + // Both pins configured — both must map to the same hardware UART + if (tx_hw != -1 && rx_hw != -1 && tx_hw == rx_hw) { + hw_uart = tx_hw; + } + } else if (tx_configured) { + hw_uart = tx_hw; + } else if (rx_configured) { + hw_uart = rx_hw; + } + #ifdef USE_LOGGER - if (tx_hw == rx_hw && logger::global_logger->get_uart() == tx_hw) { - ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", tx_hw); - tx_hw = -1; - rx_hw = -1; + if (hw_uart != -1 && logger::global_logger->get_uart() == hw_uart) { + ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", hw_uart); + hw_uart = -1; } #endif - if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) { + if (hw_uart == -1) { ESP_LOGV(TAG, "Using SerialPIO"); pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin(); pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin(); @@ -127,13 +146,15 @@ void RP2040UartComponent::setup() { } else { ESP_LOGV(TAG, "Using Hardware Serial"); SerialUART *serial; - if (tx_hw == 0) { + if (hw_uart == 0) { serial = &Serial1; } else { serial = &Serial2; } - serial->setTX(this->tx_pin_->get_pin()); - serial->setRX(this->rx_pin_->get_pin()); + if (this->tx_pin_ != nullptr) + serial->setTX(this->tx_pin_->get_pin()); + if (this->rx_pin_ != nullptr) + serial->setRX(this->rx_pin_->get_pin()); serial->setFIFOSize(this->rx_buffer_size_); serial->begin(this->baud_rate_, config); this->serial_ = serial; diff --git a/tests/components/uart/test.rp2040-ard.yaml b/tests/components/uart/test.rp2040-ard.yaml index 5eb2b533ea0..1d5f91c6a7d 100644 --- a/tests/components/uart/test.rp2040-ard.yaml +++ b/tests/components/uart/test.rp2040-ard.yaml @@ -23,3 +23,6 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_rx_only + rx_pin: 17 + baud_rate: 1200 From 14c3e2d9d948837a35aff984646bf55a9a225b33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 07:16:53 -1000 Subject: [PATCH 112/279] [api] Fix heap-buffer-overflow in protobuf message dump for StringRef (#14721) --- esphome/components/api/api_pb2_dump.cpp | 2 +- script/api_protobuf/api_protobuf.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 740bf2e47fd..5a53f0281fa 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -13,7 +13,7 @@ namespace esphome::api { static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) { out.append("'"); if (!ref.empty()) { - out.append(ref.c_str()); + out.append(ref.c_str(), ref.size()); } out.append("'"); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index b4044c362c6..dff6c7690a0 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -642,7 +642,7 @@ class StringType(TypeInfo): # For SOURCE_BOTH, check if StringRef is set (sending) or use string (received) return ( f"if (!this->{self.field_name}_ref_.empty()) {{" - f' out.append("\'").append(this->{self.field_name}_ref_.c_str()).append("\'");' + f' out.append("\'").append(this->{self.field_name}_ref_.c_str(), this->{self.field_name}_ref_.size()).append("\'");' f"}} else {{" f' out.append("\'").append(this->{self.field_name}).append("\'");' f"}}" @@ -2705,7 +2705,7 @@ namespace esphome::api { static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) { out.append("'"); if (!ref.empty()) { - out.append(ref.c_str()); + out.append(ref.c_str(), ref.size()); } out.append("'"); } From 390bb0451ffd06cb4e2d88674c9af76bb88c25f0 Mon Sep 17 00:00:00 2001 From: Brian Kaufman Date: Thu, 12 Mar 2026 16:23:29 -0700 Subject: [PATCH 113/279] [OTA] Stage exact uploaded size for ESP8266 web OTA (gzip fix) (#14741) --- esphome/components/ota/ota_backend_esp8266.cpp | 7 +++++-- esphome/components/ota/ota_backend_esp8266.h | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 1f9a77e4261..93e6249fb3d 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -105,6 +105,7 @@ OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { this->current_address_ = this->start_address_; this->image_size_ = image_size; + this->bytes_received_ = 0; this->buffer_len_ = 0; this->md5_set_ = false; @@ -140,6 +141,7 @@ OTAResponseTypes ESP8266OTABackend::write(uint8_t *data, size_t len) { size_t to_buffer = std::min(len - written, this->buffer_size_ - this->buffer_len_); memcpy(this->buffer_.get() + this->buffer_len_, data + written, to_buffer); this->buffer_len_ += to_buffer; + this->bytes_received_ += to_buffer; written += to_buffer; // If buffer is full, write to flash @@ -252,8 +254,8 @@ OTAResponseTypes ESP8266OTABackend::end() { } } - // Calculate actual bytes written - size_t actual_size = this->current_address_ - this->start_address_; + // Calculate actual bytes written (exact uploaded size, excluding flash write padding) + size_t actual_size = this->bytes_received_; // Check if any data was written if (actual_size == 0) { @@ -304,6 +306,7 @@ void ESP8266OTABackend::abort() { this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; + this->bytes_received_ = 0; esp8266::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 6213289accb..b364e216a36 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -48,6 +48,7 @@ class ESP8266OTABackend final { uint32_t start_address_{0}; uint32_t current_address_{0}; size_t image_size_{0}; + size_t bytes_received_{0}; md5::MD5Digest md5_{}; uint8_t expected_md5_[16]; // Fixed-size buffer for 128-bit (16-byte) MD5 digest From 93be53978925e7eb76035a7ca0356282bea7ae21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 13:28:25 -1000 Subject: [PATCH 114/279] [light] Fix ambiguous set_effect overload for const char* (#14732) --- .../addressable_light/addressable_light_display.h | 2 +- esphome/components/light/light_call.cpp | 2 +- esphome/components/light/light_call.h | 2 ++ tests/components/light/common.yaml | 6 ++++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 53f8604b7de..d9b8680547f 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -33,7 +33,7 @@ class AddressableLightDisplay : public display::DisplayBuffer { // - Save the current effect index. this->last_effect_index_ = light_state_->get_current_effect_index(); // - Disable any current effect. - light_state_->make_call().set_effect(0).perform(); + light_state_->make_call().set_effect(uint32_t{0}).perform(); } } enabled_ = enabled; diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 14cd0e92f69..cd45994f625 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -506,7 +506,7 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { LightCall &LightCall::set_effect(const char *effect, size_t len) { if (len == 4 && strncasecmp(effect, "none", 4) == 0) { - this->set_effect(0); + this->set_effect(uint32_t{0}); return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 0926ab6108e..0eb1785239c 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -130,6 +130,8 @@ class LightCall { LightCall &set_effect(optional effect); /// Set the effect of the light by its name. LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name (const char * overload to resolve ambiguity). + LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); } /// Set the effect of the light by its name and length (zero-copy from API). LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e5fab62a793..e1216e7b60b 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -60,6 +60,12 @@ esphome: } } + # Test set_effect with const char* doesn't cause ambiguous overload (issue #14728) + - lambda: |- + auto call = id(test_monochromatic_light).turn_on(); + call.set_effect("None"); + call.perform(); + - light.toggle: test_binary_light - light.turn_off: test_rgb_light - light.turn_on: From 0b99e8f08d0cca47ccc49e93f0e063b7315cd80b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:47:16 -1000 Subject: [PATCH 115/279] [rp2040] Use full flash for sketch in testing mode (#14747) Co-authored-by: Claude Opus 4.6 --- esphome/components/rp2040/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 276187b273c..71e5f1488cb 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -203,7 +203,12 @@ async def to_code(config): cg.add_build_flag(f"-Wl,--wrap={symbol}") cg.add_platformio_option("board_build.core", "earlephilhower") - cg.add_platformio_option("board_build.filesystem_size", "1m") + # In testing mode, use all flash for sketch to allow linking grouped component tests. + # Real RP2040 hardware uses 1MB filesystem + 1MB sketch, but CI tests may combine + # many components that exceed the 1MB sketch partition. + cg.add_platformio_option( + "board_build.filesystem_size", "0m" if CORE.testing_mode else "1m" + ) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] cg.add_define( From 910784ca841d1a117285df0eb8ba408fe6798b44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:06 -1000 Subject: [PATCH 116/279] [debug] Fix missing reset reason for RP2040/RP2350 (#14740) --- esphome/components/debug/debug_rp2040.cpp | 64 +++++++++++++++++++++-- esphome/core/helpers.h | 22 ++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index c9d41942dbc..8dc84a26732 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -1,23 +1,81 @@ #include "debug_component.h" #ifdef USE_RP2040 +#include "esphome/core/defines.h" #include "esphome/core/log.h" #include +#include +#if defined(PICO_RP2350) +#include +#else +#include +#endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif namespace esphome { namespace debug { static const char *const TAG = "debug"; -const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_reset_reason_(std::span buffer) { + char *buf = buffer.data(); + const size_t size = RESET_REASON_BUFFER_SIZE; + size_t pos = 0; + +#if defined(PICO_RP2350) + uint32_t chip_reset = powman_hw->chip_reset; + if (chip_reset & 0x04000000) // HAD_GLITCH_DETECT + pos = buf_append_str(buf, size, pos, "Power supply glitch|"); + if (chip_reset & 0x00040000) // HAD_RUN_LOW + pos = buf_append_str(buf, size, pos, "RUN pin|"); + if (chip_reset & 0x00020000) // HAD_BOR + pos = buf_append_str(buf, size, pos, "Brown-out|"); + if (chip_reset & 0x00010000) // HAD_POR + pos = buf_append_str(buf, size, pos, "Power-on reset|"); +#else + uint32_t chip_reset = vreg_and_chip_reset_hw->chip_reset; + if (chip_reset & 0x00010000) // HAD_RUN + pos = buf_append_str(buf, size, pos, "RUN pin|"); + if (chip_reset & 0x00000100) // HAD_POR + pos = buf_append_str(buf, size, pos, "Power-on reset|"); +#endif + + if (watchdog_caused_reboot()) { + bool handled = false; +#ifdef USE_RP2040_CRASH_HANDLER + if (rp2040::crash_handler_has_data()) { + pos = buf_append_str(buf, size, pos, "Crash (HardFault)|"); + handled = true; + } +#endif + if (!handled) { + if (watchdog_enable_caused_reboot()) { + pos = buf_append_str(buf, size, pos, "Watchdog timeout|"); + } else { + pos = buf_append_str(buf, size, pos, "Software reset|"); + } + } + } + + // Remove trailing '|' + if (pos > 0 && buf[pos - 1] == '|') { + buf[pos - 1] = '\0'; + } else if (pos == 0) { + return "Unknown"; + } + + return buf; +} const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return rp2040.getFreeHeap(); } +uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = rp2040.f_cpu(); + uint32_t cpu_freq = ::rp2040.f_cpu(); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 70ac1574f0c..b2517e2d7ac 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -942,6 +942,28 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, } #endif +/// Safely append a string to buffer without format parsing, returning new position (capped at size). +/// More efficient than buf_append_printf for plain string literals. +/// @param buf Output buffer +/// @param size Total buffer size +/// @param pos Current position in buffer +/// @param str String to append (must not be null) +/// @return New position after appending (capped at size on overflow) +inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str) { + if (pos >= size) { + return size; + } + size_t remaining = size - pos - 1; // reserve space for null terminator + size_t len = strlen(str); + if (len > remaining) { + len = remaining; + } + memcpy(buf + pos, str, len); + pos += len; + buf[pos] = '\0'; + return pos; +} + /// Concatenate a name with a separator and suffix using an efficient stack-based approach. /// This avoids multiple heap allocations during string construction. /// Maximum name length supported is 120 characters for friendly names. From c263c2c382292eb33bec5ed560620964b6a25c34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:29 -1000 Subject: [PATCH 117/279] [captive_portal] Fix captive portal inaccessible when web_server auth is configured (#14734) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/captive_portal/captive_portal.cpp | 4 ++-- esphome/components/web_server_base/web_server_base.cpp | 4 ++++ esphome/components/web_server_base/web_server_base.h | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5af6ab29a2e..183f16c5f84 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -61,7 +61,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { // Defer save to main loop thread to avoid NVS operations from HTTP thread this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid.c_str(), psk.c_str()); }); #endif - request->redirect(ESPHOME_F("/?save")); + request->send(200, ESPHOME_F("text/plain"), ESPHOME_F("Saved. Connecting...")); } void CaptivePortal::setup() { @@ -71,7 +71,7 @@ void CaptivePortal::setup() { void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { - this->base_->add_handler(this); + this->base_->add_handler_without_auth(this); } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index dbbcd10d8df..3e1baf34bad 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -11,6 +11,10 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif + this->add_handler_without_auth(handler); +} + +void WebServerBase::add_handler_without_auth(AsyncWebHandler *handler) { this->handlers_.push_back(handler); if (this->server_ != nullptr) { this->server_->addHandler(handler); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 54421c851e5..48e13ad71e2 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -122,6 +122,14 @@ class WebServerBase { #endif void add_handler(AsyncWebHandler *handler); + /** + * WARNING: Registers a handler that bypasses the USE_WEBSERVER_AUTH middleware. + * + * This should only be used for endpoints that are intentionally unauthenticated + * (for example, captive portal or very limited-status endpoints). For normal + * endpoints that should respect web server authentication, use add_handler(). + */ + void add_handler_without_auth(AsyncWebHandler *handler); void set_port(uint16_t port) { port_ = port; } uint16_t get_port() const { return port_; } From dc5032f72fa5571eebb2381c208f9b4deb33f93d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:41 -1000 Subject: [PATCH 118/279] [water_heater] Set OPERATION_MODE feature flag when modes are configured (#14748) --- .../template/water_heater/template_water_heater.cpp | 1 + tests/integration/test_water_heater_template.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 73081d204b4..092df6fdca3 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -26,6 +26,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { if (!this->supported_modes_.empty()) { traits.set_supported_modes(this->supported_modes_); + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE); } traits.set_supports_current_temperature(true); diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 096d4c84615..d63d1d69845 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -102,7 +102,11 @@ async def test_water_heater_template( f"Expected target temp 60.0, got {initial_state.target_temperature}" ) - # Verify supported features: away mode and on/off (fixture has away + is_on lambdas) + # Verify supported features: operation mode, away mode, and on/off + assert ( + test_water_heater.supported_features + & WaterHeaterFeature.SUPPORTS_OPERATION_MODE + ) != 0, "Expected SUPPORTS_OPERATION_MODE in supported_features" assert ( test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE ) != 0, "Expected SUPPORTS_AWAY_MODE in supported_features" From aacbaab5f800e26c7f614ec641f061dfcda4ce77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:48:55 -1000 Subject: [PATCH 119/279] [wifi] Reject EAP/WPA2 Enterprise config on unsupported platforms (#14746) --- esphome/components/wifi/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 2808d313111..480ccd65c54 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -166,6 +166,7 @@ TTLS_PHASE_2 = { } EAP_AUTH_SCHEMA = cv.All( + cv.only_on([Platform.ESP32, Platform.ESP8266]), cv.Schema( { cv.Optional(CONF_IDENTITY): cv.string_strict, From b0447dc52165abf25b18281bb0e5277e50ba896a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:49:07 -1000 Subject: [PATCH 120/279] [light] Fix binary light spamming 'brightness not supported' warning with strobe effect (#14735) --- esphome/components/light/light_call.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index cd45994f625..0b2d391fd6e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -214,7 +214,14 @@ LightColorValues LightCall::validate_() { if (this->has_brightness() && this->brightness_ == 0.0f) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - this->brightness_ = 1.0f; + if (color_mode & ColorCapability::BRIGHTNESS) { + // Reset brightness so the light has nonzero brightness when turned back on. + this->brightness_ = 1.0f; + } else { + // Light doesn't support brightness; clear the flag to avoid a spurious + // "brightness not supported" warning during capability validation. + this->clear_flag_(FLAG_HAS_BRIGHTNESS); + } } // Set color brightness to 100% if currently zero and a color is set. From 039efdb02a765f554e33f0d2c897cd69fa9fd581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 14:53:46 -1000 Subject: [PATCH 121/279] [i2c] Fix RP2040 I2C bus selection based on pin assignment (#14745) --- esphome/components/i2c/__init__.py | 37 ++++++++++++++++++++++ esphome/components/i2c/i2c_bus_arduino.cpp | 10 +++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index de3f2be6740..1684f479ba3 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -93,11 +93,31 @@ def _bus_declare_type(value): raise NotImplementedError +def _rp2040_i2c_controller(pin): + """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. + + See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): + https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf + See RP2350 datasheet Table 7 (section 9.4, "Function Select"): + https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf + """ + return (pin // 2) % 2 + + def validate_config(config): if CORE.is_esp32: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) + if CORE.is_rp2040: + sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) + scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) + if sda_controller != scl_controller: + raise cv.Invalid( + f"SDA pin GPIO{config[CONF_SDA]} is on I2C{sda_controller} but " + f"SCL pin GPIO{config[CONF_SCL]} is on I2C{scl_controller}. " + f"Both pins must be on the same I2C controller." + ) return config @@ -146,6 +166,23 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") + if CORE.is_rp2040: + if len(full_config) > 2: + raise cv.Invalid( + "The maximum number of I2C interfaces for RP2040/RP2350 is 2" + ) + if len(full_config) > 1: + controllers = [ + _rp2040_i2c_controller(conf[CONF_SDA]) for conf in full_config + ] + if len(set(controllers)) != len(controllers): + raise cv.Invalid( + "Multiple I2C buses are configured to use the same I2C controller. " + "Each bus must use pins on a different controller. " + "The I2C controller is determined by (gpio / 2) % 2: " + "even pin pairs (0-1, 4-5, 8-9, ...) use I2C0, " + "odd pin pairs (2-3, 6-7, 10-11, ...) use I2C1." + ) if CORE.is_esp32 and get_esp32_variant() in ESP32_I2C_CAPABILITIES: variant = get_esp32_variant() max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"] diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 5120eb4c007..47a06abe9ec 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -20,12 +20,14 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) #elif defined(USE_RP2040) - static bool first = true; - if (first) { + // Select Wire instance based on pin assignment, not definition order. + // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 + // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf + // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf + if ((this->sda_pin_ / 2) % 2 == 0) { wire_ = &Wire; - first = false; } else { - wire_ = &Wire1; // NOLINT(cppcoreguidelines-owning-memory) + wire_ = &Wire1; } #endif From 1ab1534028ef2052825e09301f26bb3e02f45d82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 15:12:22 -1000 Subject: [PATCH 122/279] [mdns] Fix RP2040 mDNS not restarting after WiFi reconnect (#14737) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/mdns/mdns_component.h | 4 +++ esphome/components/mdns/mdns_rp2040.cpp | 32 ++++++++++++++++++---- esphome/components/wifi/__init__.py | 7 ----- esphome/components/wifi/wifi_component.cpp | 14 ---------- esphome/components/wifi/wifi_component.h | 4 --- 5 files changed, 30 insertions(+), 31 deletions(-) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 13c8ccf2884..47cad4bf71b 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -129,6 +129,10 @@ class MDNSComponent final : public Component { #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; +#endif +#ifdef USE_RP2040 + bool was_connected_{false}; + bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 05d991c1fad..c0b22aa84fb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -36,12 +36,32 @@ static void register_rp2040(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_rp2040); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + // RP2040's LEAmDNS library registers a LwipIntf::stateUpCB() callback to restart + // mDNS when the network interface reconnects. However, stateUpCB() is stubbed out + // in arduino-pico's LwipIntfCB.cpp because the original ESP8266 implementation used + // schedule_function() which doesn't exist in arduino-pico, and the callback can't + // safely run directly since netif status callbacks fire from IRQ context + // (PICO_CYW43_ARCH_THREADSAFE_BACKGROUND) while _restart() allocates UDP sockets. + // + // Workaround: defer MDNS.begin() and service registration until the network is + // connected (has an IP), then call notifyAPChange() on subsequent reconnects to + // restart mDNS probing and announcing — all from main loop context so it's + // thread-safe. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { + bool connected = network::is_connected(); + if (connected && !this->was_connected_) { + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + } + this->was_connected_ = connected; + if (this->initialized_) { + MDNS.update(); + } + }); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 480ccd65c54..9f73b1cc6f5 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -563,13 +563,6 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) elif CORE.is_rp2040: cg.add_library("WiFi", None) - # RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart - # mDNS when the network interface reconnects. However, this callback is disabled - # in the arduino-pico framework. As a workaround, we block component setup until - # WiFi is connected via can_proceed(), ensuring mDNS.begin() is called with an - # active connection. This define enables the loop priority sorting infrastructure - # used during the setup blocking phase. - cg.add_define("USE_LOOP_PRIORITY") if CORE.is_esp32: if config[CONF_ENABLE_BTM] or config[CONF_ENABLE_RRM]: diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 60764955cc9..09f883ed617 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2109,20 +2109,6 @@ void WiFiComponent::retry_connect() { } } -#ifdef USE_RP2040 -// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart -// mDNS when the network interface reconnects. However, this callback is disabled -// in the arduino-pico framework. As a workaround, we block component setup until -// WiFi is connected, ensuring mDNS.begin() is called with an active connection. - -bool WiFiComponent::can_proceed() { - if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) { - return true; - } - return this->is_connected_(); -} -#endif - void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool WiFiComponent::is_connected_() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index f340b708c90..883cc1344b4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -437,10 +437,6 @@ class WiFiComponent : public Component { void retry_connect(); -#ifdef USE_RP2040 - bool can_proceed() override; -#endif - void set_reboot_timeout(uint32_t reboot_timeout); bool is_connected() const { return this->connected_; } From 45e40223ac3a32fdca7a535f35da73b6d866f29b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2026 15:37:46 -1000 Subject: [PATCH 123/279] [rp2040] Fix compiler warnings in crash_handler and mdns (#14739) --- esphome/components/mdns/mdns_rp2040.cpp | 5 +++++ esphome/components/rp2040/crash_handler.cpp | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index c0b22aa84fb..88f707afd37 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -7,7 +7,12 @@ #include "esphome/core/log.h" #include "mdns_component.h" +// Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. +// Save and restore our definition around the include to avoid a redefinition warning. +#pragma push_macro("IRAM_ATTR") +#undef IRAM_ATTR #include +#pragma pop_macro("IRAM_ATTR") namespace esphome::mdns { diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 1f579c2d18e..f9eb42a0f8a 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -57,14 +57,14 @@ static const char *const TAG = "rp2040.crash"; // Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). // The valid field is explicitly cleared in crash_handler_read_and_clear() instead. -static struct { +static struct CrashData { bool valid; uint32_t pc; uint32_t lr; uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} __attribute__((section(".noinit"))) s_crash_data; +} s_crash_data __attribute__((section(".noinit"))); bool crash_handler_has_data() { return s_crash_data.valid; } From 18b54f075ee02f1f36da37681968b147554f89ba Mon Sep 17 00:00:00 2001 From: Kjell Braden Date: Fri, 13 Mar 2026 14:18:42 +0100 Subject: [PATCH 124/279] [runtime_image] fix BMP parsing (#14762) --- .../components/runtime_image/bmp_decoder.h | 4 + .../fixtures/online_image_bmp.yaml | 27 ++++ tests/integration/test_online_image_bmp.py | 119 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 tests/integration/fixtures/online_image_bmp.yaml create mode 100644 tests/integration/test_online_image_bmp.py diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 73e54f54302..a52a5615849 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -26,6 +26,10 @@ class BmpDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { + if (this->bits_per_pixel_ == 0) { + // header not yet received, so dimensions not yet determined + return false; + } // BMP is finished when we've decoded all pixel data return this->paint_index_ >= static_cast(this->width_ * this->height_); } diff --git a/tests/integration/fixtures/online_image_bmp.yaml b/tests/integration/fixtures/online_image_bmp.yaml new file mode 100644 index 00000000000..e36514e9ae2 --- /dev/null +++ b/tests/integration/fixtures/online_image_bmp.yaml @@ -0,0 +1,27 @@ +esphome: + name: online-image-bmp + +host: + +http_request: + +display: + +online_image: + - url: http://127.0.0.1:HTTP_PORT/foo.bmp + id: myimg + format: BMP + type: RGB + on_download_finished: + logger.log: + format: "download finished. cache hit: %u" + args: [cached] + +api: + actions: + - action: fetch_image + then: + - component.update: myimg + +logger: + level: DEBUG diff --git a/tests/integration/test_online_image_bmp.py b/tests/integration/test_online_image_bmp.py new file mode 100644 index 00000000000..7c32154fdd6 --- /dev/null +++ b/tests/integration/test_online_image_bmp.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# black 8x8 RGB BMP, generated with +# from PIL import Image +# from io import BytesIO +# b = BytesIO() +# img = Image.new("RGB", (8, 8)) +# img.save(b, format="BMP") +# b.getvalue() +BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +LEN_BMP_IMAGE = len(BMP_IMAGE) + + +def handle_http(http_request_future): + async def handler(reader, writer): + try: + async with asyncio.timeout(1.0): + data = await reader.readuntil(b"\r\n") + + # ensure our request matches the expectation + expected_request = b"GET /foo.bmp HTTP/1.1\r\n" + assert data[: len(expected_request)] == expected_request + + # consume rest of request + async with asyncio.timeout(1.0): + data = await reader.readuntil(b"\r\n\r\n") + + http_request_future.set_result(True) + + http_response = [ + b"HTTP/1.1 200 OK", + b"Content-Length: %d" % LEN_BMP_IMAGE, + b"Content-Type: text/plain", + b"Connection: close", + b"", + b"", + ] + writer.write(b"\r\n".join(http_response)) + await writer.drain() + + writer.write(BMP_IMAGE) + + await writer.drain() + except Exception as exc: + if not http_request_future.done(): + http_request_future.set_exception(exc) + raise + finally: + writer.close() + + return handler + + +@pytest.mark.asyncio +async def test_online_image_bmp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Esphome shouldn't block the main loop when a http response is slow""" + loop = asyncio.get_running_loop() + + # Track http request + http_request_future = loop.create_future() + download_finished_future = loop.create_future() + downloaded_bytes_future = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + + if match := re.search(r"Image fully downloaded, (\d+) bytes", line): + downloaded_bytes_future.set_result(int(match.group(1))) + + if "download finished" in line: + download_finished_future.set_result(True) + + server = await asyncio.start_server( + handle_http(http_request_future), "127.0.0.1", 0 + ) + http_server_port = server.sockets[0].getsockname()[1] + + config = yaml_config.replace("HTTP_PORT", str(http_server_port)) + + # Run with log monitoring + async with ( + server, + run_compiled(config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "online-image-bmp" + + # List services to find our test service + _, services = await client.list_entities_services() + + # Find test service + request_service = next((s for s in services if s.name == "fetch_image"), None) + + assert request_service is not None, "fetch_image service not found" + + await client.execute_service(request_service, {}) + + async with asyncio.timeout(0.1): + await http_request_future + + async with asyncio.timeout(0.5): + numbytes = await downloaded_bytes_future + assert numbytes == LEN_BMP_IMAGE + await download_finished_future From e9c265914768a3c5fea24b6c2fb28eeb211cfb90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 03:20:50 -1000 Subject: [PATCH 125/279] [select] Fix -Wmaybe-uninitialized warnings on ESP8266 (#14759) --- esphome/components/select/select_call.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 45fb42c1160..83f5052fc8a 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -41,7 +41,7 @@ SelectCall &SelectCall::with_index(size_t index) { this->operation_ = SELECT_OP_SET; if (index >= this->parent_->size()) { ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index); - this->index_ = {}; // Store nullopt for invalid index + this->index_ = nullopt; // Store nullopt for invalid index } else { this->index_ = index; } @@ -52,7 +52,7 @@ optional SelectCall::calculate_target_index_(const char *name) { const auto &options = this->parent_->traits.get_options(); if (options.empty()) { ESP_LOGW(TAG, "'%s' - Select has no options", name); - return {}; + return nullopt; } if (this->operation_ == SELECT_OP_FIRST) { @@ -67,7 +67,7 @@ optional SelectCall::calculate_target_index_(const char *name) { ESP_LOGD(TAG, "'%s' - Setting", name); if (!this->index_.has_value()) { ESP_LOGW(TAG, "'%s' - No option set", name); - return {}; + return nullopt; } return this->index_; } @@ -96,7 +96,7 @@ optional SelectCall::calculate_target_index_(const char *name) { return active_index + 1; } - return {}; // Can't navigate further without cycling + return nullopt; // Can't navigate further without cycling } void SelectCall::perform() { From 49107f217463b8ceb9200fd3bb51aa38d1bb9527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 07:37:30 -1000 Subject: [PATCH 126/279] [api] Increase log Nagle coalescing on all platforms except ESP8266 (#14752) --- esphome/components/api/api_frame_helper.h | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 98de24501ea..5e07ad43a93 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -134,12 +134,16 @@ class APIFrameHelper { // // For log messages: Use Nagle to coalesce multiple small log packets into // fewer larger packets, reducing WiFi overhead. However, we limit batching - // to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained - // devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into - // shared pbufs, but holding data too long waiting for Nagle's timer causes - // buffer exhaustion and dropped messages. + // to avoid excessive LWIP buffer pressure on memory-constrained devices. + // LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but + // holding data too long waiting for Nagle's timer causes buffer exhaustion + // and dropped messages. // - // Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all) + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) + // + // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) + // Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all) // void set_nodelay_for_message(bool is_log_message) { if (!is_log_message) { @@ -150,7 +154,7 @@ class APIFrameHelper { return; } - // Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd) + // Log messages: state transitions -1 -> 1 -> ... -> LOG_NAGLE_COUNT -> -1 (flush) if (this->nodelay_state_ == NODELAY_ON) { this->set_nodelay_raw_(false); this->nodelay_state_ = 1; @@ -255,10 +259,16 @@ class APIFrameHelper { uint8_t tx_buf_tail_{0}; uint8_t tx_buf_count_{0}; // Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled - // (immediate send). Values 1-2 count log messages in the current Nagle batch. + // (immediate send). Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset. + // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. + // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. static constexpr int8_t NODELAY_ON = -1; +#ifdef USE_ESP8266 static constexpr int8_t LOG_NAGLE_COUNT = 2; +#else + static constexpr int8_t LOG_NAGLE_COUNT = 3; +#endif int8_t nodelay_state_{NODELAY_ON}; // Internal helper to set TCP_NODELAY socket option From a064eceb9bf750979c74009b508c8148eca70d81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2026 07:37:44 -1000 Subject: [PATCH 127/279] [template] Fix misleading 'Text value too long to save' warning (#14753) --- .../components/template/text/template_text.h | 32 ++--- .../fixtures/template_text_save.yaml | 23 +++ tests/integration/test_template_text_save.py | 131 ++++++++++++++++++ 3 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 tests/integration/fixtures/template_text_save.yaml create mode 100644 tests/integration/test_template_text_save.py diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 7f176db09ef..229a61d9b8e 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -24,23 +24,23 @@ class TemplateTextSaverBase { template class TextSaver : public TemplateTextSaverBase { public: bool save(const std::string &value) override { - int diff = value.compare(this->prev_); - if (diff != 0) { - // If string is bigger than the allocation, do not save it. - // We don't need to waste ram setting prev_value either. - int size = value.size(); - if (size <= SZ) { - // Make it into a length prefixed thing - unsigned char temp[SZ + 1]; - memcpy(temp + 1, value.c_str(), size); - // SZ should be pre checked at the schema level, it can't go past the char range. - temp[0] = ((unsigned char) size); - this->pref_.save(&temp); - this->prev_.assign(value); - return true; - } + if (value == this->prev_) { + return true; // No change, nothing to save } - return false; + // If string is bigger than the allocation, do not save it. + // We don't need to waste ram setting prev_value either. + int size = value.size(); + if (size > SZ) { + return false; + } + // Make it into a length prefixed thing + unsigned char temp[SZ + 1]; + memcpy(temp + 1, value.c_str(), size); + // SZ should be pre checked at the schema level, it can't go past the char range. + temp[0] = ((unsigned char) size); + this->pref_.save(&temp); + this->prev_.assign(value); + return true; } // Make the preference object. Fill the provided location with the saved data diff --git a/tests/integration/fixtures/template_text_save.yaml b/tests/integration/fixtures/template_text_save.yaml new file mode 100644 index 00000000000..526561732de --- /dev/null +++ b/tests/integration/fixtures/template_text_save.yaml @@ -0,0 +1,23 @@ +esphome: + name: host-template-text-save-test + +host: + +api: + batch_delay: 0ms + +logger: + +preferences: + flash_write_interval: 0s + +text: + - platform: template + name: "Test Text Restore" + id: test_text_restore + optimistic: true + min_length: 0 + max_length: 10 + mode: text + initial_value: "hello" + restore_value: true diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py new file mode 100644 index 00000000000..47c8e3188ab --- /dev/null +++ b/tests/integration/test_template_text_save.py @@ -0,0 +1,131 @@ +"""Integration test for template text restore_value persistence. + +Tests that: +1. A template text with restore_value saves its value to preferences +2. The saved value persists across restarts (binary re-run) +3. Setting the same value again does not produce a spurious "too long" warning +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + + +@pytest.mark.asyncio +async def test_template_text_save( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test template text save/restore persistence and duplicate-save behavior.""" + port, port_socket = reserved_tcp_port + + # Clean up any stale preference file from previous runs + prefs_file = ( + Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs" + ) + if prefs_file.exists(): + prefs_file.unlink() + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + # --- First run: set a value and verify no spurious warnings --- + warning_lines: list[str] = [] + + def capture_warnings(line: str) -> None: + if "too long to save" in line.lower(): + warning_lines.append(line) + + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=capture_warnings + ), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == "host-template-text-save-test" + + entities, _ = await client.list_entities_services() + text_entity = require_entity( + entities, "test_text_restore", TextInfo, "Test Text Restore" + ) + + # Set up state tracking + loop = asyncio.get_running_loop() + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Verify initial value from config + initial = initial_state_helper.initial_states[text_entity.key] + assert isinstance(initial, TextState) + assert initial.state == "hello" + + async def wait_for_state(key: int, timeout: float = 2.0) -> Any: + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Set a new value that fits within max_length + client.text_command(key=text_entity.key, state="world") + state = await wait_for_state(text_entity.key) + assert state.state == "world" + + # Set the same value again - should NOT produce "too long" warning + client.text_command(key=text_entity.key, state="world") + # Give time for the warning to appear (if any) + await asyncio.sleep(0.5) + + # No warnings should have appeared + assert warning_lines == [], ( + f"Unexpected 'too long to save' warning(s): {warning_lines}" + ) + + # --- Second run: verify the value was restored from preferences --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + text_entity = require_entity( + entities, "test_text_restore", TextInfo, "Test Text Restore" + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None)) + await initial_state_helper.wait_for_initial_states() + + # The value should be "world" - restored from preferences + restored = initial_state_helper.initial_states[text_entity.key] + assert isinstance(restored, TextState) + assert restored.state == "world", ( + f"Expected restored value 'world', got '{restored.state}'" + ) + + # Clean up preference file + if prefs_file.exists(): + prefs_file.unlink() From 98d98716203da8d8d7a42f3312bb547ce2c425cb Mon Sep 17 00:00:00 2001 From: leccelecce <24962424+leccelecce@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:15:54 +0000 Subject: [PATCH 128/279] [online_image] Log download duration in milliseconds instead of seconds (#14803) --- esphome/components/online_image/online_image.cpp | 6 +++--- esphome/components/online_image/online_image.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index da866599c9d..22bf6a3056f 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -129,7 +129,7 @@ void OnlineImage::update() { } ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size); - this->start_time_ = ::time(nullptr); + this->start_time_ = millis(); this->enable_loop(); } @@ -155,8 +155,8 @@ void OnlineImage::loop() { // Finalize decoding this->end_decode(); - ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 "s", this->downloader_->get_bytes_read(), - (uint32_t) (::time(nullptr) - this->start_time_)); + ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), + millis() - this->start_time_); // Save caching headers this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index c7c80c7c667..12c25645260 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -97,7 +97,7 @@ class OnlineImage : public PollingComponent, */ std::string last_modified_ = ""; - time_t start_time_; + uint32_t start_time_{0}; }; template class OnlineImageSetUrlAction : public Action { From 632dbc8fe83a7de72841ad72965a4e484898cc81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 08:12:04 -1000 Subject: [PATCH 129/279] [core] Inline LwIPLock as no-op on platforms without lwIP core locking (#14787) --- esphome/components/esp8266/helpers.cpp | 4 +--- esphome/components/libretiny/helpers.cpp | 4 +--- esphome/components/zephyr/core.cpp | 4 +--- esphome/core/helpers.h | 22 +++++++++++++++------- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp8266/helpers.cpp b/esphome/components/esp8266/helpers.cpp index 036594fa178..4a64ae181e3 100644 --- a/esphome/components/esp8266/helpers.cpp +++ b/esphome/components/esp8266/helpers.cpp @@ -22,9 +22,7 @@ void Mutex::unlock() {} IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } -// ESP8266 doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// ESP8266 LwIPLock is defined inline as a no-op in helpers.h void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) wifi_get_macaddr(STATION_IF, mac); diff --git a/esphome/components/libretiny/helpers.cpp b/esphome/components/libretiny/helpers.cpp index 37ae0fb455a..21913e4a16d 100644 --- a/esphome/components/libretiny/helpers.cpp +++ b/esphome/components/libretiny/helpers.cpp @@ -26,9 +26,7 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); } IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); } -// LibreTiny doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// LibreTiny LwIPLock is defined inline as a no-op in helpers.h void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) WiFi.macAddress(mac); diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index eee7fb3f4f8..1d105a10572 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -76,9 +76,7 @@ void Mutex::unlock() { k_mutex_unlock(static_cast(this->handle_)); } IRAM_ATTR InterruptLock::InterruptLock() { state_ = irq_lock(); } IRAM_ATTR InterruptLock::~InterruptLock() { irq_unlock(state_); } -// Zephyr doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// Zephyr LwIPLock is defined inline as a no-op in helpers.h uint32_t random_uint32() { return rand(); } // NOLINT(cert-msc30-c, cert-msc50-cpp) bool random_bytes(uint8_t *data, size_t len) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b2517e2d7ac..dafd899ae43 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1801,19 +1801,27 @@ class InterruptLock { /** Helper class to lock the lwIP TCPIP core when making lwIP API calls from non-TCPIP threads. * - * This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled. - * It ensures thread-safe access to lwIP APIs. + * This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled, + * and on RP2040 when CYW43 WiFi is active (cyw43_arch_lwip_begin/end). * - * @note This follows the same pattern as InterruptLock - platform-specific implementations in helpers.cpp + * On platforms without lwIP core locking (ESP8266, LibreTiny, Zephyr), + * this is a no-op defined inline so the compiler can eliminate all call overhead. */ class LwIPLock { public: - LwIPLock(); - ~LwIPLock(); - - // Delete copy constructor and copy assignment operator to prevent accidental copying LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; + +#if defined(USE_ESP32) || defined(USE_RP2040) + // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp + LwIPLock(); + ~LwIPLock(); +#else + // No lwIP core locking — inline no-ops (empty bodies instead of = default + // to prevent clang-tidy unused-variable warnings at call sites) + LwIPLock() {} + ~LwIPLock() {} +#endif }; /** Helper class to request `loop()` to be called as fast as possible. From 22ea2764d4c48edfb365150a11be9c557806f9bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 13:17:32 -1000 Subject: [PATCH 130/279] [debug] Fix shared buffer between reset reason and wakeup cause (#14813) --- esphome/components/debug/debug_component.h | 3 ++- esphome/components/debug/debug_esp32.cpp | 9 +++++---- esphome/components/debug/debug_esp8266.cpp | 2 +- esphome/components/debug/debug_host.cpp | 2 +- esphome/components/debug/debug_libretiny.cpp | 2 +- esphome/components/debug/debug_rp2040.cpp | 2 +- esphome/components/debug/debug_zephyr.cpp | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index e4f4bb36eba..3da6b800c6f 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -18,6 +18,7 @@ namespace debug { static constexpr size_t DEVICE_INFO_BUFFER_SIZE = 256; static constexpr size_t RESET_REASON_BUFFER_SIZE = 128; +static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h @@ -94,7 +95,7 @@ class DebugComponent : public PollingComponent { #endif // USE_TEXT_SENSOR const char *get_reset_reason_(std::span buffer); - const char *get_wakeup_cause_(std::span buffer); + const char *get_wakeup_cause_(std::span buffer); uint32_t get_free_heap_(); size_t get_device_info_(std::span buffer, size_t pos); void update_platform_(); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 6898621dd05..c9df4fdf210 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -98,7 +98,7 @@ static const char *const WAKEUP_CAUSES[] = { "BT", }; -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { const char *wake_reason; unsigned reason = esp_sleep_get_wakeup_cause(); if (reason < sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0])) { @@ -196,9 +196,10 @@ size_t DebugComponent::get_device_info_(std::span uint32_t cpu_freq_mhz = arch_get_cpu_freq_hz() / 1000000; pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32 " MHz", cpu_freq_mhz); - char reason_buffer[RESET_REASON_BUFFER_SIZE]; - const char *reset_reason = get_reset_reason_(std::span(reason_buffer)); - const char *wakeup_cause = get_wakeup_cause_(std::span(reason_buffer)); + char reset_buffer[RESET_REASON_BUFFER_SIZE]; + char wakeup_buffer[WAKEUP_CAUSE_BUFFER_SIZE]; + const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); + const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); uint8_t mac[6]; get_mac_address_raw(mac); diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 4df4aaa8513..0519ab72fe9 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -91,7 +91,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // ESP8266 doesn't have detailed wakeup cause like ESP32 return ""; } diff --git a/esphome/components/debug/debug_host.cpp b/esphome/components/debug/debug_host.cpp index 2fa88f0909c..0dfab86e4c4 100644 --- a/esphome/components/debug/debug_host.cpp +++ b/esphome/components/debug/debug_host.cpp @@ -7,7 +7,7 @@ namespace debug { const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 39269d6f2f2..1d458c602a6 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -12,7 +12,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return lt_heap_get_free(); } diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index 8dc84a26732..73f08492c86 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -67,7 +67,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index bd6432e9499..bf87b7ae3dc 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -53,7 +53,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // Zephyr doesn't have detailed wakeup cause like ESP32 return ""; } From deb6b97eea98feea201c73402df493c30a502e7d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:25:21 +1300 Subject: [PATCH 131/279] Bump version to 2026.3.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9f5cd0a2ce3..4ec3a24c9fe 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.3.0b1 +PROJECT_NUMBER = 2026.3.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index eb49c9a1d77..2466f2c49c0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0b1" +__version__ = "2026.3.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 92d5e7b18c233403ea58835e12fffd4a52431ede Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 15 Mar 2026 16:02:23 -0700 Subject: [PATCH 132/279] [tests] Fix integration helper to match entities exactly (#14837) Co-authored-by: J. Nick Koston --- .../fixtures/sensor_filters_nan_handling.yaml | 18 +++++++++--------- .../fixtures/sensor_filters_ring_buffer.yaml | 18 +++++++++--------- .../sensor_filters_ring_buffer_wraparound.yaml | 8 ++++---- tests/integration/state_utils.py | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/integration/fixtures/sensor_filters_nan_handling.yaml b/tests/integration/fixtures/sensor_filters_nan_handling.yaml index fcb12cfde58..beaf55eacf8 100644 --- a/tests/integration/fixtures/sensor_filters_nan_handling.yaml +++ b/tests/integration/fixtures/sensor_filters_nan_handling.yaml @@ -3,7 +3,7 @@ esphome: host: api: - batch_delay: 0ms # Disable batching to receive all state updates + batch_delay: 0ms # Disable batching to receive all state updates logger: level: DEBUG @@ -15,8 +15,8 @@ sensor: - platform: copy source_id: source_nan_sensor - name: "Min NaN Sensor" - id: min_nan_sensor + name: "Min NaN" + id: min_nan filters: - min: window_size: 5 @@ -25,8 +25,8 @@ sensor: - platform: copy source_id: source_nan_sensor - name: "Max NaN Sensor" - id: max_nan_sensor + name: "Max NaN" + id: max_nan filters: - max: window_size: 5 @@ -42,7 +42,7 @@ script: - delay: 20ms - sensor.template.publish: id: source_nan_sensor - state: !lambda 'return NAN;' + state: !lambda "return NAN;" - delay: 20ms - sensor.template.publish: id: source_nan_sensor @@ -50,7 +50,7 @@ script: - delay: 20ms - sensor.template.publish: id: source_nan_sensor - state: !lambda 'return NAN;' + state: !lambda "return NAN;" - delay: 20ms - sensor.template.publish: id: source_nan_sensor @@ -62,7 +62,7 @@ script: - delay: 20ms - sensor.template.publish: id: source_nan_sensor - state: !lambda 'return NAN;' + state: !lambda "return NAN;" - delay: 20ms - sensor.template.publish: id: source_nan_sensor @@ -74,7 +74,7 @@ script: - delay: 20ms - sensor.template.publish: id: source_nan_sensor - state: !lambda 'return NAN;' + state: !lambda "return NAN;" button: - platform: template diff --git a/tests/integration/fixtures/sensor_filters_ring_buffer.yaml b/tests/integration/fixtures/sensor_filters_ring_buffer.yaml index ea7a326b8dd..b9b8ed8f74d 100644 --- a/tests/integration/fixtures/sensor_filters_ring_buffer.yaml +++ b/tests/integration/fixtures/sensor_filters_ring_buffer.yaml @@ -3,7 +3,7 @@ esphome: host: api: - batch_delay: 0ms # Disable batching to receive all state updates + batch_delay: 0ms # Disable batching to receive all state updates logger: level: DEBUG @@ -18,8 +18,8 @@ sensor: # Window of 5, send every 2 values - platform: copy source_id: source_sensor - name: "Sliding Min Sensor" - id: sliding_min_sensor + name: "Sliding Min" + id: sliding_min filters: - min: window_size: 5 @@ -28,8 +28,8 @@ sensor: - platform: copy source_id: source_sensor - name: "Sliding Max Sensor" - id: sliding_max_sensor + name: "Sliding Max" + id: sliding_max filters: - max: window_size: 5 @@ -38,8 +38,8 @@ sensor: - platform: copy source_id: source_sensor - name: "Sliding Median Sensor" - id: sliding_median_sensor + name: "Sliding Median" + id: sliding_median filters: - median: window_size: 5 @@ -48,8 +48,8 @@ sensor: - platform: copy source_id: source_sensor - name: "Sliding Moving Avg Sensor" - id: sliding_moving_avg_sensor + name: "Sliding Moving Avg" + id: sliding_moving_avg filters: - sliding_window_moving_average: window_size: 5 diff --git a/tests/integration/fixtures/sensor_filters_ring_buffer_wraparound.yaml b/tests/integration/fixtures/sensor_filters_ring_buffer_wraparound.yaml index bd5980160bf..d1528e4438a 100644 --- a/tests/integration/fixtures/sensor_filters_ring_buffer_wraparound.yaml +++ b/tests/integration/fixtures/sensor_filters_ring_buffer_wraparound.yaml @@ -3,20 +3,20 @@ esphome: host: api: - batch_delay: 0ms # Disable batching to receive all state updates + batch_delay: 0ms # Disable batching to receive all state updates logger: level: DEBUG sensor: - platform: template - name: "Source Wraparound Sensor" + name: "Source Wraparound" id: source_wraparound accuracy_decimals: 2 - platform: copy source_id: source_wraparound - name: "Wraparound Min Sensor" - id: wraparound_min_sensor + name: "Wraparound Min" + id: wraparound_min filters: - min: window_size: 3 diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index ab9fdb01bb9..5792a8e8046 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -88,7 +88,7 @@ def build_key_to_entity_mapping( Args: entities: List of entity info objects from the API - entity_names: List of entity names to search for in object_ids + entity_names: List of entity names to match exactly against object_ids Returns: Dictionary mapping entity keys to entity names @@ -97,7 +97,7 @@ def build_key_to_entity_mapping( for entity in entities: obj_id = entity.object_id.lower() for entity_name in entity_names: - if entity_name in obj_id: + if entity_name == obj_id: key_to_entity[entity.key] = entity_name break return key_to_entity From d97c23b8e3c8cb7e7feec411c58fe0c3a4a7b006 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 15:13:10 -1000 Subject: [PATCH 133/279] [core] Add no-arg status_set_warning() to allow linker GC of const char* overload (#14821) --- esphome/components/rx8130/rx8130.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.cpp | 2 +- esphome/components/zwave_proxy/zwave_proxy.cpp | 2 +- esphome/core/component.cpp | 1 + esphome/core/component.h | 3 ++- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 9e6f05ee15c..07ed7acc565 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -68,7 +68,7 @@ void RX8130Component::dump_config() { void RX8130Component::read_time() { uint8_t date[7]; if (this->read_register(RX8130_REG_SEC, date, 7) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESPTime rtc_time{ @@ -109,7 +109,7 @@ void RX8130Component::write_time() { buff[6] = dec2bcd(now.year % 100); this->stop_(true); if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } else { ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, now.minute, now.second); @@ -120,7 +120,7 @@ void RX8130Component::write_time() { void RX8130Component::stop_(bool stop) { const uint8_t data = stop ? RX8130_BIT_CTRL_STOP : RX8130_CLEAR_FLAGS; if (this->write_register(RX8130_REG_CTRL0, &data, 1) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3d35f368fb8..997f8361464 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -416,7 +416,7 @@ void USBUartTypeCdcAcm::on_connected() { for (auto *channel : this->channels_) { if (i == cdc_devs.size()) { ESP_LOGE(TAG, "No configuration found for channel %d", channel->index_); - this->status_set_warning("No configuration found for channel"); + this->status_set_warning(LOG_STR("No configuration found for channel")); break; } channel->cdc_dev_ = cdc_devs[i++]; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 9e5c57814d2..ad4357663ff 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -90,7 +90,7 @@ void ZWaveProxy::process_uart_() { while (this->available()) { uint8_t byte; if (!this->read_byte(&byte)) { - this->status_set_warning("UART read failed"); + this->status_set_warning(LOG_STR("UART read failed")); return; } if (this->parse_byte_(byte)) { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index cce0c7b3e04..761f1bd4858 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -392,6 +392,7 @@ bool Component::set_status_flag_(uint8_t flag) { return true; } +void Component::status_set_warning() { this->status_set_warning((const LogString *) nullptr); } void Component::status_set_warning(const char *message) { if (!this->set_status_flag_(STATUS_LED_WARNING)) return; diff --git a/esphome/core/component.h b/esphome/core/component.h index 7266f57e151..1aac1c72191 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -240,7 +240,8 @@ class Component { bool status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } - void status_set_warning(const char *message = nullptr); + void status_set_warning(); // Set warning flag without message + void status_set_warning(const char *message); void status_set_warning(const LogString *message); void status_set_error(); // Set error flag without message From 29501ef4f87870db3e9bee59c13681a99534ca00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 15:13:34 -1000 Subject: [PATCH 134/279] [core] Mark leaf Component subclasses as final (#14833) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/api/api_server.h | 6 +++--- esphome/components/binary_sensor/filter.h | 2 +- esphome/components/gpio/binary_sensor/gpio_binary_sensor.h | 2 +- esphome/components/gpio/switch/gpio_switch.h | 2 +- esphome/components/homeassistant/time/homeassistant_time.h | 2 +- esphome/components/md5/md5.h | 2 +- esphome/components/preferences/syncer.h | 2 +- esphome/components/restart/button/restart_button.h | 2 +- esphome/components/safe_mode/button/safe_mode_button.h | 2 +- esphome/components/sha256/sha256.h | 2 +- esphome/components/version/version_text_sensor.h | 2 +- esphome/components/wifi/wifi_component.h | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 69fc26cc00c..ccba6deb00a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -36,11 +36,11 @@ struct SavedNoisePsk { } PACKED; // NOLINT #endif -class APIServer : public Component, - public Controller +class APIServer final : public Component, + public Controller #ifdef USE_CAMERA , - public camera::CameraListener + public camera::CameraListener #endif { public: diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 2735a32ab07..0813847ca21 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -37,7 +37,7 @@ class TimeoutFilter : public Filter, public Component { TemplatableValue timeout_delay_{}; }; -class DelayedOnOffFilter : public Filter, public Component { +class DelayedOnOffFilter final : public Filter, public Component { public: optional new_value(bool value) override; diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 8cf52f540b3..8b1cc296134 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -39,7 +39,7 @@ class GPIOBinarySensorStore { Component *component_{nullptr}; // Pointer to the component for enable_loop_soon_any_context() }; -class GPIOBinarySensor : public binary_sensor::BinarySensor, public Component { +class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { public: // No destructor needed: ESPHome components are created at boot and live forever. // Interrupts are only detached on reboot when memory is cleared anyway. diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 080decac082..a73fb9e18cc 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -8,7 +8,7 @@ namespace esphome { namespace gpio { -class GPIOSwitch : public switch_::Switch, public Component { +class GPIOSwitch final : public switch_::Switch, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/homeassistant/time/homeassistant_time.h b/esphome/components/homeassistant/time/homeassistant_time.h index 7b5842fefdb..455ded20226 100644 --- a/esphome/components/homeassistant/time/homeassistant_time.h +++ b/esphome/components/homeassistant/time/homeassistant_time.h @@ -7,7 +7,7 @@ namespace esphome { namespace homeassistant { -class HomeassistantTime : public time::RealTimeClock { +class HomeassistantTime final : public time::RealTimeClock { public: void setup() override; void update() override; diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 6ff651b02ed..80e74d188ef 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -32,7 +32,7 @@ namespace esphome { namespace md5 { -class MD5Digest : public HashBase { +class MD5Digest final : public HashBase { public: MD5Digest() = default; ~MD5Digest() override; diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index b6b422d4bae..96716d3f304 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -6,7 +6,7 @@ namespace esphome { namespace preferences { -class IntervalSyncer : public Component { +class IntervalSyncer final : public Component { public: void set_write_interval(uint32_t write_interval) { this->write_interval_ = write_interval; } void setup() override { diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index db18f1dadcd..fd51282d36d 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -6,7 +6,7 @@ namespace esphome { namespace restart { -class RestartButton : public button::Button, public Component { +class RestartButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/safe_mode/button/safe_mode_button.h b/esphome/components/safe_mode/button/safe_mode_button.h index fea0955abb6..0307a81febd 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.h +++ b/esphome/components/safe_mode/button/safe_mode_button.h @@ -7,7 +7,7 @@ namespace esphome { namespace safe_mode { -class SafeModeButton : public button::Button, public Component { +class SafeModeButton final : public button::Button, public Component { public: void dump_config() override; void set_safe_mode(SafeModeComponent *safe_mode_component); diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 0f995fcd916..d10d418c7a8 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -48,7 +48,7 @@ namespace esphome::sha256 { /// hasher.init(); /// hasher.add(data, len); /// hasher.calculate(); -class SHA256 : public esphome::HashBase { +class SHA256 final : public esphome::HashBase { public: SHA256() = default; ~SHA256() override; diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index fec898ae038..d2ca0ba6f65 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::version { -class VersionTextSensor : public text_sensor::TextSensor, public Component { +class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: void set_hide_hash(bool hide_hash); void set_hide_timestamp(bool hide_timestamp); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index aeb32352a97..057f2c06613 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -399,7 +399,7 @@ class WiFiPowerSaveListener { }; /// This component is responsible for managing the ESP WiFi interface. -class WiFiComponent : public Component { +class WiFiComponent final : public Component { public: /// Construct a WiFiComponent. WiFiComponent(); From 1377776d218bc8061d7bb9da96517e1e34105867 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 15:17:21 -1000 Subject: [PATCH 135/279] [ethernet] Restructure for multi-platform support (#14819) --- esphome/components/ethernet/__init__.py | 306 ++++--- .../ethernet/ethernet_component.cpp | 850 +----------------- .../components/ethernet/ethernet_component.h | 89 +- .../ethernet/ethernet_component_esp32.cpp | 841 +++++++++++++++++ .../components/ethernet/ethernet_helpers.c | 3 + .../ethernet_info_text_sensor.cpp | 4 +- .../ethernet_info/ethernet_info_text_sensor.h | 4 +- esphome/core/defines.h | 6 + 8 files changed, 1099 insertions(+), 1004 deletions(-) create mode 100644 esphome/components/ethernet/ethernet_component_esp32.cpp diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index e520c0e914d..83bef4d91cb 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,23 +2,8 @@ import logging from esphome import automation, pins import esphome.codegen as cg -from esphome.components.esp32 import ( - VARIANT_ESP32, - VARIANT_ESP32C3, - VARIANT_ESP32C5, - VARIANT_ESP32C6, - VARIANT_ESP32C61, - VARIANT_ESP32P4, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - add_idf_component, - add_idf_sdkconfig_option, - get_esp32_variant, - idf_version, - include_builtin_idf_component, -) from esphome.components.network import ip_address_literal -from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -50,6 +35,8 @@ from esphome.const import ( CONF_VALUE, KEY_CORE, KEY_FRAMEWORK_VERSION, + Platform, + PlatformFramework, ) from esphome.core import ( CORE, @@ -61,7 +48,6 @@ import esphome.final_validate as fv from esphome.types import ConfigType CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) @@ -174,9 +160,16 @@ EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") -def _is_framework_spi_polling_mode_supported(): - # SPI Ethernet without IRQ feature is added in - # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) +def _is_framework_spi_polling_mode_supported() -> bool: + """Check if ESP-IDF framework supports SPI polling mode (ESP32 only). + + SPI Ethernet without IRQ feature is added in + esp-idf >= (5.3+, 5.2.1+, 5.1.4) + """ + if not CORE.is_esp32: + return False + from esphome.components.esp32 import idf_version + ver = idf_version() if ver >= cv.Version(5, 3, 0): return True @@ -195,52 +188,63 @@ def _validate(config): use_address = CORE.name + config[CONF_DOMAIN] config[CONF_USE_ADDRESS] = use_address - if config[CONF_TYPE] in SPI_ETHERNET_TYPES: - if _is_framework_spi_polling_mode_supported(): - if CONF_POLLING_INTERVAL in config and CONF_INTERRUPT_PIN in config: - raise cv.Invalid( - f"Cannot specify more than one of {CONF_INTERRUPT_PIN}, {CONF_POLLING_INTERVAL}" - ) - if CONF_POLLING_INTERVAL not in config and CONF_INTERRUPT_PIN not in config: - config[CONF_POLLING_INTERVAL] = SPI_ETHERNET_DEFAULT_POLLING_INTERVAL - else: - if CONF_POLLING_INTERVAL in config: - raise cv.Invalid( - "In this version of the framework " - f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " - f"'{CONF_POLLING_INTERVAL}' is not supported." - ) - if CONF_INTERRUPT_PIN not in config: - raise cv.Invalid( - "In this version of the framework " - f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " - f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." - ) - elif config[CONF_TYPE] != "OPENETH": - if CONF_CLK_MODE in config: - mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] - LOGGER.warning( - "[ethernet] The 'clk_mode' option is deprecated. " - "Please replace 'clk_mode: %s' with:\n" - " clk:\n" - " mode: %s\n" - " pin: %s\n" - "Removal scheduled for 2026.7.0.", - config[CONF_CLK_MODE], - mode, - pin, - ) - config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) - del config[CONF_CLK_MODE] - elif CONF_CLK not in config: - raise cv.Invalid("'clk' is a required option for [ethernet].") - variant = get_esp32_variant() - if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): - raise cv.Invalid( - f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " - f"on ESP32 classic and ESP32-P4, not {variant}" + if CORE.is_esp32: + if config[CONF_TYPE] in SPI_ETHERNET_TYPES: + if _is_framework_spi_polling_mode_supported(): + if CONF_POLLING_INTERVAL in config and CONF_INTERRUPT_PIN in config: + raise cv.Invalid( + f"Cannot specify more than one of {CONF_INTERRUPT_PIN}, {CONF_POLLING_INTERVAL}" + ) + if ( + CONF_POLLING_INTERVAL not in config + and CONF_INTERRUPT_PIN not in config + ): + config[CONF_POLLING_INTERVAL] = ( + SPI_ETHERNET_DEFAULT_POLLING_INTERVAL + ) + else: + if CONF_POLLING_INTERVAL in config: + raise cv.Invalid( + "In this version of the framework " + f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " + f"'{CONF_POLLING_INTERVAL}' is not supported." + ) + if CONF_INTERRUPT_PIN not in config: + raise cv.Invalid( + "In this version of the framework " + f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " + f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." + ) + elif config[CONF_TYPE] != "OPENETH": + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32P4, + get_esp32_variant, ) + if CONF_CLK_MODE in config: + mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] + LOGGER.warning( + "[ethernet] The 'clk_mode' option is deprecated. " + "Please replace 'clk_mode: %s' with:\n" + " clk:\n" + " mode: %s\n" + " pin: %s\n" + "Removal scheduled for 2026.7.0.", + config[CONF_CLK_MODE], + mode, + pin, + ) + config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) + del config[CONF_CLK_MODE] + elif CONF_CLK not in config: + raise cv.Invalid("'clk' is a required option for [ethernet].") + variant = get_esp32_variant() + if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): + raise cv.Invalid( + f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " + f"on ESP32 classic and ESP32-P4, not {variant}" + ) return config @@ -269,41 +273,47 @@ CLK_SCHEMA = cv.Schema( cv.Required(CONF_PIN): pins.internal_gpio_pin_number, } ) -RMII_SCHEMA = BASE_SCHEMA.extend( - cv.Schema( - { - cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLK_MODE): cv.enum( - CLK_MODES_DEPRECATED, upper=True, space="_" - ), - cv.Optional(CONF_CLK): CLK_SCHEMA, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), - cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), - } - ) +RMII_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_CLK_MODE): cv.enum( + CLK_MODES_DEPRECATED, upper=True, space="_" + ), + cv.Optional(CONF_CLK): CLK_SCHEMA, + cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), + } + ) + ), + cv.only_on([Platform.ESP32]), ) -SPI_SCHEMA = BASE_SCHEMA.extend( - cv.Schema( - { - cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, - cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, - cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLOCK_SPEED, default="26.67MHz"): cv.All( - cv.frequency, cv.int_range(int(8e6), int(80e6)) - ), - # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() - cv.Optional(CONF_POLLING_INTERVAL): cv.All( - cv.positive_time_period_milliseconds, - cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), - ), - } +SPI_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_CLOCK_SPEED, default="26.67MHz"): cv.All( + cv.frequency, cv.int_range(int(8e6), int(80e6)) + ), + # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), + ), + } + ), ), + cv.only_on([Platform.ESP32]), ) CONFIG_SCHEMA = cv.All( @@ -317,7 +327,7 @@ CONFIG_SCHEMA = cv.All( "KSZ8081": RMII_SCHEMA, "KSZ8081RNA": RMII_SCHEMA, "W5500": SPI_SCHEMA, - "OPENETH": BASE_SCHEMA, + "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "LAN8670": RMII_SCHEMA, }, @@ -328,8 +338,21 @@ CONFIG_SCHEMA = cv.All( def _final_validate_spi(config): + if not CORE.is_esp32: + return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: return + from esphome.components.esp32 import ( + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + get_esp32_variant, + ) + from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface + if spi_configs := fv.full_config.get().get(CONF_SPI): variant = get_esp32_variant() if variant in ( @@ -378,6 +401,47 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if CORE.is_esp32: + await _to_code_esp32(var, config) + + cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) + cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + + if CONF_MANUAL_IP in config: + cg.add_define("USE_ETHERNET_MANUAL_IP") + cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) + + # Add compile-time define for PHY types with specific code + if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): + cg.add_define(phy_define) + + if mac_address := config.get(CONF_MAC_ADDRESS): + cg.add(var.set_fixed_mac(mac_address.parts)) + + cg.add_define("USE_ETHERNET") + + if on_connect_config := config.get(CONF_ON_CONNECT): + cg.add_define("USE_ETHERNET_CONNECT_TRIGGER") + await automation.build_automation( + var.get_connect_trigger(), [], on_connect_config + ) + + if on_disconnect_config := config.get(CONF_ON_DISCONNECT): + cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER") + await automation.build_automation( + var.get_disconnect_trigger(), [], on_disconnect_config + ) + + CORE.add_job(final_step) + + +async def _to_code_esp32(var, config): + from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + include_builtin_idf_component, + ) + if config[CONF_TYPE] in SPI_ETHERNET_TYPES: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) @@ -415,22 +479,6 @@ async def to_code(config): ) cg.add(var.add_phy_register(reg)) - cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) - - if CONF_MANUAL_IP in config: - cg.add_define("USE_ETHERNET_MANUAL_IP") - cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) - - # Add compile-time define for PHY types with specific code - if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): - cg.add_define(phy_define) - - if mac_address := config.get(CONF_MAC_ADDRESS): - cg.add(var.set_fixed_mac(mac_address.parts)) - - cg.add_define("USE_ETHERNET") - # Disable WiFi when using Ethernet to save memory add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) # Also disable WiFi/BT coexistence since WiFi is disabled @@ -443,27 +491,21 @@ async def to_code(config): # Add LAN867x 10BASE-T1S PHY support component add_idf_component(name="espressif/lan867x", ref="2.0.0") - if on_connect_config := config.get(CONF_ON_CONNECT): - cg.add_define("USE_ETHERNET_CONNECT_TRIGGER") - await automation.build_automation( - var.get_connect_trigger(), [], on_connect_config - ) - - if on_disconnect_config := config.get(CONF_ON_DISCONNECT): - cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER") - await automation.build_automation( - var.get_disconnect_trigger(), [], on_disconnect_config - ) - - CORE.add_job(final_step) - def _final_validate_rmii_pins(config: ConfigType) -> None: """Validate that RMII pins are not used by other components.""" + if not CORE.is_esp32: + return # RMII validation is ESP32-only # Only validate for RMII-based PHYs on ESP32/ESP32P4 if config[CONF_TYPE] in SPI_ETHERNET_TYPES or config[CONF_TYPE] == "OPENETH": return # SPI and OPENETH don't use RMII + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32P4, + get_esp32_variant, + ) + variant = get_esp32_variant() if variant == VARIANT_ESP32: rmii_pins = ESP32_RMII_FIXED_PINS @@ -521,3 +563,13 @@ async def final_step(): if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") cg.add_define("ESPHOME_ETHERNET_IP_STATE_LISTENERS", ip_state_count) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "ethernet_component_esp32.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, + } +) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index e0788e11498..4421a1c7aaf 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -1,547 +1,28 @@ #include "ethernet_component.h" -#include "esphome/core/application.h" -#include "esphome/core/helpers.h" + +#ifdef USE_ETHERNET + #include "esphome/core/log.h" -#include "esphome/core/util.h" - -#ifdef USE_ESP32 - -#include -#include -#include "esp_event.h" - -#ifdef USE_ETHERNET_LAN8670 -#include "esp_eth_phy_lan867x.h" -#endif - -#ifdef USE_ETHERNET_SPI -#include -#include -#endif namespace esphome::ethernet { -static const char *const TAG = "ethernet"; - -// PHY register size for hex logging -static constexpr size_t PHY_REG_SIZE = 2; - EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { - ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); - this->mark_failed(); -} - -#define ESPHL_ERROR_CHECK(err, message) \ - if ((err) != ESP_OK) { \ - this->log_error_and_mark_failed_(err, message); \ - return; \ - } - -#define ESPHL_ERROR_CHECK_RET(err, message, ret) \ - if ((err) != ESP_OK) { \ - this->log_error_and_mark_failed_(err, message); \ - return ret; \ - } - EthernetComponent::EthernetComponent() { global_eth_component = this; } -void EthernetComponent::setup() { - if (esp_reset_reason() != ESP_RST_DEEPSLEEP) { - // Delay here to allow power to stabilise before Ethernet is initialized. - delay(300); // NOLINT - } - - esp_err_t err; - -#ifdef USE_ETHERNET_SPI - // Install GPIO ISR handler to be able to service SPI Eth modules interrupts - gpio_install_isr_service(0); - - spi_bus_config_t buscfg = { - .mosi_io_num = this->mosi_pin_, - .miso_io_num = this->miso_pin_, - .sclk_io_num = this->clk_pin_, - .quadwp_io_num = -1, - .quadhd_io_num = -1, - .data4_io_num = -1, - .data5_io_num = -1, - .data6_io_num = -1, - .data7_io_num = -1, - .max_transfer_sz = 0, - .flags = 0, - .intr_flags = 0, - }; - -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) - auto host = SPI2_HOST; -#else - auto host = SPI3_HOST; -#endif - - err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); - ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); -#endif - - err = esp_netif_init(); - ESPHL_ERROR_CHECK(err, "ETH netif init error"); - err = esp_event_loop_create_default(); - ESPHL_ERROR_CHECK(err, "ETH event loop error"); - - esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); - this->eth_netif_ = esp_netif_new(&cfg); - - // Init MAC and PHY configs to default - eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); - eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); - -#ifdef USE_ETHERNET_SPI // Configure SPI interface and Ethernet driver for specific SPI module - spi_device_interface_config_t devcfg = { - .command_bits = 0, - .address_bits = 0, - .dummy_bits = 0, - .mode = 0, - .duty_cycle_pos = 0, - .cs_ena_pretrans = 0, - .cs_ena_posttrans = 0, - .clock_speed_hz = this->clock_speed_, - .input_delay_ns = 0, - .spics_io_num = this->cs_pin_, - .flags = 0, - .queue_size = 20, - .pre_cb = nullptr, - .post_cb = nullptr, - }; - -#if CONFIG_ETH_SPI_ETHERNET_W5500 - eth_w5500_config_t w5500_config = ETH_W5500_DEFAULT_CONFIG(host, &devcfg); -#endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); -#endif - -#if CONFIG_ETH_SPI_ETHERNET_W5500 - w5500_config.int_gpio_num = this->interrupt_pin_; -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - w5500_config.poll_period_ms = this->polling_interval_; -#endif -#endif - -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - dm9051_config.int_gpio_num = this->interrupt_pin_; -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - dm9051_config.poll_period_ms = this->polling_interval_; -#endif -#endif - - phy_config.phy_addr = this->phy_addr_spi_; - phy_config.reset_gpio_num = this->reset_pin_; - - esp_eth_mac_t *mac = nullptr; -#elif defined(USE_ETHERNET_OPENETH) - esp_eth_mac_t *mac = esp_eth_mac_new_openeth(&mac_config); -#else - phy_config.phy_addr = this->phy_addr_; - phy_config.reset_gpio_num = this->power_pin_; - - eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) - esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; - esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; -#else - esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; - esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; -#endif - esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t) this->clk_pin_; - - esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); -#endif - - switch (this->type_) { -#ifdef USE_ETHERNET_OPENETH - case ETHERNET_TYPE_OPENETH: { - phy_config.autonego_timeout_ms = 1000; - this->phy_ = esp_eth_phy_new_dp83848(&phy_config); - break; - } -#endif -#if CONFIG_ETH_USE_ESP32_EMAC -#ifdef USE_ETHERNET_LAN8720 - case ETHERNET_TYPE_LAN8720: { - this->phy_ = esp_eth_phy_new_lan87xx(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_RTL8201 - case ETHERNET_TYPE_RTL8201: { - this->phy_ = esp_eth_phy_new_rtl8201(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_DP83848 - case ETHERNET_TYPE_DP83848: { - this->phy_ = esp_eth_phy_new_dp83848(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_IP101 - case ETHERNET_TYPE_IP101: { - this->phy_ = esp_eth_phy_new_ip101(&phy_config); - break; - } -#endif -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) - case ETHERNET_TYPE_JL1101: { - this->phy_ = esp_eth_phy_new_jl1101(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_KSZ8081 - case ETHERNET_TYPE_KSZ8081: - case ETHERNET_TYPE_KSZ8081RNA: { - this->phy_ = esp_eth_phy_new_ksz80xx(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_LAN8670 - case ETHERNET_TYPE_LAN8670: { - this->phy_ = esp_eth_phy_new_lan867x(&phy_config); - break; - } -#endif -#endif -#ifdef USE_ETHERNET_SPI -#if CONFIG_ETH_SPI_ETHERNET_W5500 - case ETHERNET_TYPE_W5500: { - mac = esp_eth_mac_new_w5500(&w5500_config, &mac_config); - this->phy_ = esp_eth_phy_new_w5500(&phy_config); - break; - } -#endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - case ETHERNET_TYPE_DM9051: { - mac = esp_eth_mac_new_dm9051(&dm9051_config, &mac_config); - this->phy_ = esp_eth_phy_new_dm9051(&phy_config); - break; - } -#endif -#endif - default: { - this->mark_failed(); - return; - } - } - - esp_eth_config_t eth_config = ETH_DEFAULT_CONFIG(mac, this->phy_); - this->eth_handle_ = nullptr; - err = esp_eth_driver_install(ð_config, &this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH driver install error"); - -#ifndef USE_ETHERNET_SPI -#ifdef USE_ETHERNET_KSZ8081 - if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { - // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. - this->ksz8081_set_clock_reference_(mac); - } -#endif // USE_ETHERNET_KSZ8081 - - for (const auto &phy_register : this->phy_registers_) { - this->write_phy_register_(mac, phy_register); - } -#endif - - // use ESP internal eth mac - uint8_t mac_addr[6]; - if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); - } else { - esp_read_mac(mac_addr, ESP_MAC_ETH); - } - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_MAC_ADDR, mac_addr); - ESPHL_ERROR_CHECK(err, "set mac address error"); - - /* attach Ethernet driver to TCP/IP stack */ - err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); - ESPHL_ERROR_CHECK(err, "ETH netif attach error"); - - // Register user defined event handers - err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); - ESPHL_ERROR_CHECK(err, "ETH event handler register error"); - err = esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, &EthernetComponent::got_ip_event_handler, nullptr); - ESPHL_ERROR_CHECK(err, "GOT IP event handler register error"); -#if USE_NETWORK_IPV6 - err = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, &EthernetComponent::got_ip6_event_handler, nullptr); - ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); -#endif /* USE_NETWORK_IPV6 */ - - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); -} - -void EthernetComponent::loop() { - const uint32_t now = App.get_loop_component_start_time(); - - switch (this->state_) { - case EthernetComponentState::STOPPED: - if (this->started_) { - ESP_LOGI(TAG, "Starting connection"); - this->state_ = EthernetComponentState::CONNECTING; - this->start_connect_(); - } - break; - case EthernetComponentState::CONNECTING: - if (!this->started_) { - ESP_LOGI(TAG, "Stopped connection"); - this->state_ = EthernetComponentState::STOPPED; - } else if (this->connected_) { - // connection established - ESP_LOGI(TAG, "Connected"); - this->state_ = EthernetComponentState::CONNECTED; - - this->dump_connect_params_(); - this->status_clear_warning(); -#ifdef USE_ETHERNET_CONNECT_TRIGGER - this->connect_trigger_.trigger(); -#endif - } else if (now - this->connect_begin_ > 15000) { - ESP_LOGW(TAG, "Connecting failed; reconnecting"); - this->start_connect_(); - } - break; - case EthernetComponentState::CONNECTED: - if (!this->started_) { - ESP_LOGI(TAG, "Stopped connection"); - this->state_ = EthernetComponentState::STOPPED; -#ifdef USE_ETHERNET_DISCONNECT_TRIGGER - this->disconnect_trigger_.trigger(); -#endif - } else if (!this->connected_) { - ESP_LOGW(TAG, "Connection lost; reconnecting"); - this->state_ = EthernetComponentState::CONNECTING; - this->start_connect_(); -#ifdef USE_ETHERNET_DISCONNECT_TRIGGER - this->disconnect_trigger_.trigger(); -#endif - } else { - this->finish_connect_(); - // When connected and stable, disable the loop to save CPU cycles - this->disable_loop(); - } - break; - } -} - -void EthernetComponent::dump_config() { - const char *eth_type; - switch (this->type_) { -#ifdef USE_ETHERNET_LAN8720 - case ETHERNET_TYPE_LAN8720: - eth_type = "LAN8720"; - break; -#endif -#ifdef USE_ETHERNET_RTL8201 - case ETHERNET_TYPE_RTL8201: - eth_type = "RTL8201"; - break; -#endif -#ifdef USE_ETHERNET_DP83848 - case ETHERNET_TYPE_DP83848: - eth_type = "DP83848"; - break; -#endif -#ifdef USE_ETHERNET_IP101 - case ETHERNET_TYPE_IP101: - eth_type = "IP101"; - break; -#endif -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) - case ETHERNET_TYPE_JL1101: - eth_type = "JL1101"; - break; -#endif -#ifdef USE_ETHERNET_KSZ8081 - case ETHERNET_TYPE_KSZ8081: - eth_type = "KSZ8081"; - break; - - case ETHERNET_TYPE_KSZ8081RNA: - eth_type = "KSZ8081RNA"; - break; -#endif -#if CONFIG_ETH_SPI_ETHERNET_W5500 - case ETHERNET_TYPE_W5500: - eth_type = "W5500"; - break; -#endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - case ETHERNET_TYPE_DM9051: - eth_type = "DM9051"; - break; -#endif -#ifdef USE_ETHERNET_OPENETH - case ETHERNET_TYPE_OPENETH: - eth_type = "OPENETH"; - break; -#endif -#ifdef USE_ETHERNET_LAN8670 - case ETHERNET_TYPE_LAN8670: - eth_type = "LAN8670"; - break; -#endif - - default: - eth_type = "Unknown"; - break; - } - - ESP_LOGCONFIG(TAG, - "Ethernet:\n" - " Connected: %s", - YESNO(this->is_connected())); - this->dump_connect_params_(); -#ifdef USE_ETHERNET_SPI - ESP_LOGCONFIG(TAG, - " CLK Pin: %u\n" - " MISO Pin: %u\n" - " MOSI Pin: %u\n" - " CS Pin: %u", - this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - if (this->polling_interval_ != 0) { - ESP_LOGCONFIG(TAG, " Polling Interval: %lu ms", this->polling_interval_); - } else -#endif - { - ESP_LOGCONFIG(TAG, " IRQ Pin: %d", this->interrupt_pin_); - } - ESP_LOGCONFIG(TAG, - " Reset Pin: %d\n" - " Clock Speed: %d MHz", - this->reset_pin_, this->clock_speed_ / 1000000); -#else - if (this->power_pin_ != -1) { - ESP_LOGCONFIG(TAG, " Power Pin: %u", this->power_pin_); - } - ESP_LOGCONFIG(TAG, - " CLK Pin: %u\n" - " MDC Pin: %u\n" - " MDIO Pin: %u\n" - " PHY addr: %u", - this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); -#endif - ESP_LOGCONFIG(TAG, " Type: %s", eth_type); -} - float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } -network::IPAddresses EthernetComponent::get_ip_addresses() { - network::IPAddresses addresses; - esp_netif_ip_info_t ip; - esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err)); - // TODO: do something smarter - // return false; - } else { - addresses[0] = network::IPAddress(&ip.ip); - } -#if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } -#endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } - return addresses; -} - -network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { - LwIPLock lock; - const ip_addr_t *dns_ip = dns_getserver(num); - return dns_ip; -} - -void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event, void *event_data) { - const char *event_name; - - switch (event) { - case ETHERNET_EVENT_START: - event_name = "ETH started"; - global_eth_component->started_ = true; - global_eth_component->enable_loop_soon_any_context(); - break; - case ETHERNET_EVENT_STOP: - event_name = "ETH stopped"; - global_eth_component->started_ = false; - global_eth_component->connected_ = false; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes - break; - case ETHERNET_EVENT_CONNECTED: - event_name = "ETH connected"; - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP) - if (global_eth_component->manual_ip_.has_value()) { - global_eth_component->notify_ip_state_listeners_(); - } +#ifdef USE_ETHERNET_MANUAL_IP +void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } #endif - break; - case ETHERNET_EVENT_DISCONNECTED: - event_name = "ETH disconnected"; - global_eth_component->connected_ = false; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes - break; - default: - return; - } - ESP_LOGV(TAG, "[Ethernet event] %s (num=%" PRId32 ")", event_name, event); -} +// set_use_address() is guaranteed to be called during component setup by Python code generation, +// so use_address_ will always be valid when get_use_address() is called - no fallback needed. +const char *EthernetComponent::get_use_address() const { return this->use_address_; } -void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, - void *event_data) { - ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data; - const esp_netif_ip_info_t *ip_info = &event->ip_info; - ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip)); - global_eth_component->got_ipv4_address_ = true; -#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) - global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#else - global_eth_component->connected_ = true; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#endif /* USE_NETWORK_IPV6 */ -#ifdef USE_ETHERNET_IP_STATE_LISTENERS - global_eth_component->notify_ip_state_listeners_(); -#endif -} - -#if USE_NETWORK_IPV6 -void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, - void *event_data) { - ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; - ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); - global_eth_component->ipv6_count_ += 1; -#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) - global_eth_component->connected_ = - global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#else - global_eth_component->connected_ = global_eth_component->got_ipv4_address_; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#endif -#ifdef USE_ETHERNET_IP_STATE_LISTENERS - global_eth_component->notify_ip_state_listeners_(); -#endif -} -#endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; } #ifdef USE_ETHERNET_IP_STATE_LISTENERS void EthernetComponent::notify_ip_state_listeners_() { @@ -554,315 +35,6 @@ void EthernetComponent::notify_ip_state_listeners_() { } #endif // USE_ETHERNET_IP_STATE_LISTENERS -void EthernetComponent::finish_connect_() { -#if USE_NETWORK_IPV6 - // Retry IPv6 link-local setup if it failed during initial connect - // This handles the case where min_ipv6_addr_count is NOT set (or is 0), - // allowing us to reach CONNECTED state with just IPv4. - // If IPv6 setup failed in start_connect_() because the interface wasn't ready: - // - Bootup timing issues (#10281) - // - Cable unplugged/network interruption (#10705) - // We can now retry since we're in CONNECTED state and the interface is definitely up. - if (!this->ipv6_setup_done_) { - esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err == ESP_OK) { - ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); - } - // Always set the flag to prevent continuous retries - // If IPv6 setup fails here with the interface up and stable, it's - // likely a persistent issue (IPv6 disabled at router, hardware - // limitation, etc.) that won't be resolved by further retries. - // The device continues to work with IPv4. - this->ipv6_setup_done_ = true; - } -#endif /* USE_NETWORK_IPV6 */ -} - -void EthernetComponent::start_connect_() { - global_eth_component->got_ipv4_address_ = false; -#if USE_NETWORK_IPV6 - global_eth_component->ipv6_count_ = 0; - this->ipv6_setup_done_ = false; -#endif /* USE_NETWORK_IPV6 */ - this->connect_begin_ = millis(); - this->status_set_warning(LOG_STR("waiting for IP configuration")); - - esp_err_t err; - err = esp_netif_set_hostname(this->eth_netif_, App.get_name().c_str()); - if (err != ERR_OK) { - ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); - } - - esp_netif_ip_info_t info; -#ifdef USE_ETHERNET_MANUAL_IP - if (this->manual_ip_.has_value()) { - info.ip = this->manual_ip_->static_ip; - info.gw = this->manual_ip_->gateway; - info.netmask = this->manual_ip_->subnet; - } else -#endif - { - info.ip.addr = 0; - info.gw.addr = 0; - info.netmask.addr = 0; - } - - esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT; - - err = esp_netif_dhcpc_get_status(this->eth_netif_, &status); - ESPHL_ERROR_CHECK(err, "DHCPC Get Status Failed!"); - - ESP_LOGV(TAG, "DHCP Client Status: %d", status); - - err = esp_netif_dhcpc_stop(this->eth_netif_); - if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { - ESPHL_ERROR_CHECK(err, "DHCPC stop error"); - } - - err = esp_netif_set_ip_info(this->eth_netif_, &info); - ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); - -#ifdef USE_ETHERNET_MANUAL_IP - if (this->manual_ip_.has_value()) { - LwIPLock lock; - if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); - } - if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); - } - } else -#endif - { - err = esp_netif_dhcpc_start(this->eth_netif_); - if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { - ESPHL_ERROR_CHECK(err, "DHCPC start error"); - } - } -#if USE_NETWORK_IPV6 - // Attempt to create IPv6 link-local address - // We MUST attempt this here, not just in finish_connect_(), because with - // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. - // However, this may fail with ESP_FAIL if the interface is not up yet: - // - At bootup when link isn't ready (#10281) - // - After disconnection/cable unplugged (#10705) - // We'll retry in finish_connect_() if it fails here. - err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err != ESP_OK) { - if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { - // This is a programming error, not a transient failure - ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); - } else { - // ESP_FAIL means the interface isn't up yet - // This is expected and non-fatal, happens in multiple scenarios: - // - During reconnection after network interruptions (#10705) - // - At bootup when the link isn't ready yet (#10281) - // We'll retry once we reach CONNECTED state and the interface is up - ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); - // Don't mark component as failed - this is a transient error - } - } -#endif /* USE_NETWORK_IPV6 */ - - this->connect_begin_ = millis(); - this->status_set_warning(); -} - -void EthernetComponent::dump_connect_params_() { - esp_netif_ip_info_t ip; - esp_netif_get_ip_info(this->eth_netif_, &ip); - const ip_addr_t *dns_ip1; - const ip_addr_t *dns_ip2; - { - LwIPLock lock; - dns_ip1 = dns_getserver(0); - dns_ip2 = dns_getserver(1); - } - - // Use stack buffers for IP address formatting to avoid heap allocations - char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGCONFIG(TAG, - " IP Address: %s\n" - " Hostname: '%s'\n" - " Subnet: %s\n" - " Gateway: %s\n" - " DNS1: %s\n" - " DNS2: %s\n" - " MAC Address: %s\n" - " Is Full Duplex: %s\n" - " Link Speed: %u", - network::IPAddress(&ip.ip).str_to(ip_buf), App.get_name().c_str(), - network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), - network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), - this->get_eth_mac_address_pretty_into_buffer(mac_buf), - YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); - -#if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - for (int i = 0; i < count; i++) { - ESP_LOGCONFIG(TAG, " IPv6: " IPV6STR, IPV62STR(if_ip6s[i])); - } -#endif /* USE_NETWORK_IPV6 */ -} - -#ifdef USE_ETHERNET_SPI -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } -void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT -void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } -#endif -#else -void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } -void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } -void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } -void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } -void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } -#endif -void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } -#ifdef USE_ETHERNET_MANUAL_IP -void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } -#endif - -// set_use_address() is guaranteed to be called during component setup by Python code generation, -// so use_address_ will always be valid when get_use_address() is called - no fallback needed. -const char *EthernetComponent::get_use_address() const { return this->use_address_; } - -void EthernetComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; } - -void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { - esp_err_t err; - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); - ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); -} - -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - -const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( - std::span buf) { - uint8_t mac[6]; - get_eth_mac_address_raw(mac); - format_mac_addr_upper(mac, buf.data()); - return buf.data(); -} - -eth_duplex_t EthernetComponent::get_duplex_mode() { - esp_err_t err; - eth_duplex_t duplex_mode; - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); - ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_DUPLEX_MODE error", ETH_DUPLEX_HALF); - return duplex_mode; -} - -eth_speed_t EthernetComponent::get_link_speed() { - esp_err_t err; - eth_speed_t speed; - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); - ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_SPEED error", ETH_SPEED_10M); - return speed; -} - -bool EthernetComponent::powerdown() { - ESP_LOGI(TAG, "Powering down ethernet PHY"); - if (this->phy_ == nullptr) { - ESP_LOGE(TAG, "Ethernet PHY not assigned"); - return false; - } - this->connected_ = false; - this->started_ = false; - // No need to enable_loop() here as this is only called during shutdown/reboot - if (this->phy_->pwrctl(this->phy_, false) != ESP_OK) { - ESP_LOGE(TAG, "Error powering down ethernet PHY"); - return false; - } - return true; -} - -#ifndef USE_ETHERNET_SPI - -#ifdef USE_ETHERNET_KSZ8081 -constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; - -void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { - esp_err_t err; - - uint32_t phy_control_2; - err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); - ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(PHY_REG_SIZE)]; -#endif - ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); - - /* - * Bit 7 is `RMII Reference Clock Select`. Default is `0`. - * KSZ8081RNA: - * 0 - clock input to XI (Pin 8) is 25 MHz for RMII - 25 MHz clock mode. - * 1 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. - * KSZ8081RND: - * 0 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. - * 1 - clock input to XI (Pin 8) is 25 MHz (driven clock only, not a crystal) for RMII - 25 MHz clock mode. - */ - if ((phy_control_2 & (1 << 7)) != (1 << 7)) { - phy_control_2 |= 1 << 7; - err = mac->write_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, phy_control_2); - ESPHL_ERROR_CHECK(err, "Write PHY Control 2 failed"); - err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); - ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); - ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", - format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); - } -} -#endif // USE_ETHERNET_KSZ8081 - -void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { - esp_err_t err; - -#ifdef USE_ETHERNET_RTL8201 - constexpr uint8_t eth_phy_psr_reg_addr = 0x1F; - if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { - ESP_LOGD(TAG, "Select PHY Register Page: 0x%02" PRIX32, register_data.page); - err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, register_data.page); - ESPHL_ERROR_CHECK(err, "Select PHY Register page failed"); - } -#endif - - ESP_LOGD(TAG, "Writing PHY reg 0x%02" PRIX32 " = 0x%04" PRIX32, register_data.address, register_data.value); - err = mac->write_phy_reg(mac, this->phy_addr_, register_data.address, register_data.value); - ESPHL_ERROR_CHECK(err, "Writing PHY Register failed"); - -#ifdef USE_ETHERNET_RTL8201 - if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { - ESP_LOGD(TAG, "Select PHY Register Page 0x00"); - err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, 0x0); - ESPHL_ERROR_CHECK(err, "Select PHY Register Page 0 failed"); - } -#endif -} - -#endif - } // namespace esphome::ethernet -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index f7a0996fb74..80038d50ecb 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -7,8 +7,9 @@ #include "esphome/core/automation.h" #include "esphome/components/network/ip_address.h" -#ifdef USE_ESP32 +#ifdef USE_ETHERNET +#ifdef USE_ESP32 #include "esp_eth.h" #include "esp_eth_mac.h" #include "esp_eth_mac_esp.h" @@ -19,6 +20,7 @@ #if CONFIG_ETH_USE_ESP32_EMAC extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #endif +#endif // USE_ESP32 namespace esphome::ethernet { @@ -73,6 +75,12 @@ enum class EthernetComponentState : uint8_t { CONNECTED, }; +// Platform-neutral duplex/speed types +#ifndef USE_ESP32 +enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; +enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; +#endif + class EthernetComponent : public Component { public: EthernetComponent(); @@ -83,6 +91,28 @@ class EthernetComponent : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + void set_type(EthernetType type); +#ifdef USE_ETHERNET_MANUAL_IP + void set_manual_ip(const ManualIP &manual_ip); +#endif + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + + network::IPAddresses get_ip_addresses(); + network::IPAddress get_dns_address(uint8_t num); + const char *get_use_address() const; + void set_use_address(const char *use_address); + void get_eth_mac_address_raw(uint8_t *mac); + // Remove before 2026.9.0 + ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") + std::string get_eth_mac_address_pretty(); + const char *get_eth_mac_address_pretty_into_buffer(std::span buf); + eth_duplex_t get_duplex_mode(); + eth_speed_t get_link_speed(); + bool powerdown(); + +#ifdef USE_ESP32 + esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } + #ifdef USE_ETHERNET_SPI void set_clk_pin(uint8_t clk_pin); void set_miso_pin(uint8_t miso_pin); @@ -102,26 +132,8 @@ class EthernetComponent : public Component { void set_clk_pin(uint8_t clk_pin); void set_clk_mode(emac_rmii_clock_mode_t clk_mode); void add_phy_register(PHYRegister register_value); -#endif - void set_type(EthernetType type); -#ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); -#endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } - - network::IPAddresses get_ip_addresses(); - network::IPAddress get_dns_address(uint8_t num); - const char *get_use_address() const; - void set_use_address(const char *use_address); - void get_eth_mac_address_raw(uint8_t *mac); - // Remove before 2026.9.0 - ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") - std::string get_eth_mac_address_pretty(); - const char *get_eth_mac_address_pretty_into_buffer(std::span buf); - eth_duplex_t get_duplex_mode(); - eth_speed_t get_link_speed(); - esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } - bool powerdown(); +#endif // USE_ETHERNET_SPI +#endif // USE_ESP32 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -133,19 +145,22 @@ class EthernetComponent : public Component { #ifdef USE_ETHERNET_DISCONNECT_TRIGGER Trigger<> *get_disconnect_trigger() { return &this->disconnect_trigger_; } #endif + protected: + void start_connect_(); + void finish_connect_(); + void dump_connect_params_(); + +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + void notify_ip_state_listeners_(); +#endif + +#ifdef USE_ESP32 static void eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); static void got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #if LWIP_IPV6 static void got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif /* LWIP_IPV6 */ -#ifdef USE_ETHERNET_IP_STATE_LISTENERS - void notify_ip_state_listeners_(); -#endif - - void start_connect_(); - void finish_connect_(); - void dump_connect_params_(); void log_error_and_mark_failed_(esp_err_t err, const char *message); #ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. @@ -177,7 +192,15 @@ class EthernetComponent : public Component { uint8_t phy_addr_{0}; uint8_t mdc_pin_{23}; uint8_t mdio_pin_{18}; -#endif +#endif // USE_ETHERNET_SPI + + // ESP32 pointers + esp_netif_t *eth_netif_{nullptr}; + esp_eth_handle_t eth_handle_; + esp_eth_phy_t *phy_{nullptr}; +#endif // USE_ESP32 + + // Common members #ifdef USE_ETHERNET_MANUAL_IP optional manual_ip_{}; #endif @@ -194,10 +217,6 @@ class EthernetComponent : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - // Pointers at the end (naturally aligned) - esp_netif_t *eth_netif_{nullptr}; - esp_eth_handle_t eth_handle_; - esp_eth_phy_t *phy_{nullptr}; optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS @@ -219,10 +238,12 @@ class EthernetComponent : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern EthernetComponent *global_eth_component; +#ifdef USE_ESP32 #if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) extern "C" esp_eth_phy_t *esp_eth_phy_new_jl1101(const eth_phy_config_t *config); #endif +#endif // USE_ESP32 } // namespace esphome::ethernet -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp new file mode 100644 index 00000000000..ac8680f3e13 --- /dev/null +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -0,0 +1,841 @@ +#include "ethernet_component.h" + +#if defined(USE_ETHERNET) && defined(USE_ESP32) + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include "esp_event.h" + +#ifdef USE_ETHERNET_LAN8670 +#include "esp_eth_phy_lan867x.h" +#endif + +#ifdef USE_ETHERNET_SPI +#include +#include +#endif + +namespace esphome::ethernet { + +static const char *const TAG = "ethernet"; + +// PHY register size for hex logging +static constexpr size_t PHY_REG_SIZE = 2; + +void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { + ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); + this->mark_failed(); +} + +#define ESPHL_ERROR_CHECK(err, message) \ + if ((err) != ESP_OK) { \ + this->log_error_and_mark_failed_(err, message); \ + return; \ + } + +#define ESPHL_ERROR_CHECK_RET(err, message, ret) \ + if ((err) != ESP_OK) { \ + this->log_error_and_mark_failed_(err, message); \ + return ret; \ + } + +void EthernetComponent::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + switch (this->state_) { + case EthernetComponentState::STOPPED: + if (this->started_) { + ESP_LOGI(TAG, "Starting connection"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTING: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; + } else if (this->connected_) { + // connection established + ESP_LOGI(TAG, "Connected"); + this->state_ = EthernetComponentState::CONNECTED; + + this->dump_connect_params_(); + this->status_clear_warning(); +#ifdef USE_ETHERNET_CONNECT_TRIGGER + this->connect_trigger_.trigger(); +#endif + } else if (now - this->connect_begin_ > 15000) { + ESP_LOGW(TAG, "Connecting failed; reconnecting"); + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTED: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else if (!this->connected_) { + ESP_LOGW(TAG, "Connection lost; reconnecting"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else { + this->finish_connect_(); + // When connected and stable, disable the loop to save CPU cycles + this->disable_loop(); + } + break; + } +} + +void EthernetComponent::setup() { + if (esp_reset_reason() != ESP_RST_DEEPSLEEP) { + // Delay here to allow power to stabilise before Ethernet is initialized. + delay(300); // NOLINT + } + + esp_err_t err; + +#ifdef USE_ETHERNET_SPI + // Install GPIO ISR handler to be able to service SPI Eth modules interrupts + gpio_install_isr_service(0); + + spi_bus_config_t buscfg = { + .mosi_io_num = this->mosi_pin_, + .miso_io_num = this->miso_pin_, + .sclk_io_num = this->clk_pin_, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .data4_io_num = -1, + .data5_io_num = -1, + .data6_io_num = -1, + .data7_io_num = -1, + .max_transfer_sz = 0, + .flags = 0, + .intr_flags = 0, + }; + +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + auto host = SPI2_HOST; +#else + auto host = SPI3_HOST; +#endif + + err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); + ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); +#endif + + err = esp_netif_init(); + ESPHL_ERROR_CHECK(err, "ETH netif init error"); + err = esp_event_loop_create_default(); + ESPHL_ERROR_CHECK(err, "ETH event loop error"); + + esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); + this->eth_netif_ = esp_netif_new(&cfg); + + // Init MAC and PHY configs to default + eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); + eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); + +#ifdef USE_ETHERNET_SPI // Configure SPI interface and Ethernet driver for specific SPI module + spi_device_interface_config_t devcfg = { + .command_bits = 0, + .address_bits = 0, + .dummy_bits = 0, + .mode = 0, + .duty_cycle_pos = 0, + .cs_ena_pretrans = 0, + .cs_ena_posttrans = 0, + .clock_speed_hz = this->clock_speed_, + .input_delay_ns = 0, + .spics_io_num = this->cs_pin_, + .flags = 0, + .queue_size = 20, + .pre_cb = nullptr, + .post_cb = nullptr, + }; + +#if CONFIG_ETH_SPI_ETHERNET_W5500 + eth_w5500_config_t w5500_config = ETH_W5500_DEFAULT_CONFIG(host, &devcfg); +#endif +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); +#endif + +#if CONFIG_ETH_SPI_ETHERNET_W5500 + w5500_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + w5500_config.poll_period_ms = this->polling_interval_; +#endif +#endif + +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + dm9051_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + dm9051_config.poll_period_ms = this->polling_interval_; +#endif +#endif + + phy_config.phy_addr = this->phy_addr_spi_; + phy_config.reset_gpio_num = this->reset_pin_; + + esp_eth_mac_t *mac = nullptr; +#elif defined(USE_ETHERNET_OPENETH) + esp_eth_mac_t *mac = esp_eth_mac_new_openeth(&mac_config); +#else + phy_config.phy_addr = this->phy_addr_; + phy_config.reset_gpio_num = this->power_pin_; + + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) + esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; + esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; +#else + esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; + esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; +#endif + esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; + esp32_emac_config.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t) this->clk_pin_; + + esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); +#endif + + switch (this->type_) { +#ifdef USE_ETHERNET_OPENETH + case ETHERNET_TYPE_OPENETH: { + phy_config.autonego_timeout_ms = 1000; + this->phy_ = esp_eth_phy_new_dp83848(&phy_config); + break; + } +#endif +#if CONFIG_ETH_USE_ESP32_EMAC +#ifdef USE_ETHERNET_LAN8720 + case ETHERNET_TYPE_LAN8720: { + this->phy_ = esp_eth_phy_new_lan87xx(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_RTL8201 + case ETHERNET_TYPE_RTL8201: { + this->phy_ = esp_eth_phy_new_rtl8201(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_DP83848 + case ETHERNET_TYPE_DP83848: { + this->phy_ = esp_eth_phy_new_dp83848(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_IP101 + case ETHERNET_TYPE_IP101: { + this->phy_ = esp_eth_phy_new_ip101(&phy_config); + break; + } +#endif +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) + case ETHERNET_TYPE_JL1101: { + this->phy_ = esp_eth_phy_new_jl1101(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_KSZ8081 + case ETHERNET_TYPE_KSZ8081: + case ETHERNET_TYPE_KSZ8081RNA: { + this->phy_ = esp_eth_phy_new_ksz80xx(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_LAN8670 + case ETHERNET_TYPE_LAN8670: { + this->phy_ = esp_eth_phy_new_lan867x(&phy_config); + break; + } +#endif +#endif +#ifdef USE_ETHERNET_SPI +#if CONFIG_ETH_SPI_ETHERNET_W5500 + case ETHERNET_TYPE_W5500: { + mac = esp_eth_mac_new_w5500(&w5500_config, &mac_config); + this->phy_ = esp_eth_phy_new_w5500(&phy_config); + break; + } +#endif +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + case ETHERNET_TYPE_DM9051: { + mac = esp_eth_mac_new_dm9051(&dm9051_config, &mac_config); + this->phy_ = esp_eth_phy_new_dm9051(&phy_config); + break; + } +#endif +#endif + default: { + this->mark_failed(); + return; + } + } + + esp_eth_config_t eth_config = ETH_DEFAULT_CONFIG(mac, this->phy_); + this->eth_handle_ = nullptr; + err = esp_eth_driver_install(ð_config, &this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH driver install error"); + +#ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 + if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { + // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. + this->ksz8081_set_clock_reference_(mac); + } +#endif // USE_ETHERNET_KSZ8081 + + for (const auto &phy_register : this->phy_registers_) { + this->write_phy_register_(mac, phy_register); + } +#endif + + // use ESP internal eth mac + uint8_t mac_addr[6]; + if (this->fixed_mac_.has_value()) { + memcpy(mac_addr, this->fixed_mac_->data(), 6); + } else { + esp_read_mac(mac_addr, ESP_MAC_ETH); + } + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_MAC_ADDR, mac_addr); + ESPHL_ERROR_CHECK(err, "set mac address error"); + + /* attach Ethernet driver to TCP/IP stack */ + err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); + ESPHL_ERROR_CHECK(err, "ETH netif attach error"); + + // Register user defined event handers + err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); + ESPHL_ERROR_CHECK(err, "ETH event handler register error"); + err = esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, &EthernetComponent::got_ip_event_handler, nullptr); + ESPHL_ERROR_CHECK(err, "GOT IP event handler register error"); +#if USE_NETWORK_IPV6 + err = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, &EthernetComponent::got_ip6_event_handler, nullptr); + ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); +#endif /* USE_NETWORK_IPV6 */ + + /* start Ethernet driver state machine */ + err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); +} + +void EthernetComponent::dump_config() { + const char *eth_type; + switch (this->type_) { +#ifdef USE_ETHERNET_LAN8720 + case ETHERNET_TYPE_LAN8720: + eth_type = "LAN8720"; + break; +#endif +#ifdef USE_ETHERNET_RTL8201 + case ETHERNET_TYPE_RTL8201: + eth_type = "RTL8201"; + break; +#endif +#ifdef USE_ETHERNET_DP83848 + case ETHERNET_TYPE_DP83848: + eth_type = "DP83848"; + break; +#endif +#ifdef USE_ETHERNET_IP101 + case ETHERNET_TYPE_IP101: + eth_type = "IP101"; + break; +#endif +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) + case ETHERNET_TYPE_JL1101: + eth_type = "JL1101"; + break; +#endif +#ifdef USE_ETHERNET_KSZ8081 + case ETHERNET_TYPE_KSZ8081: + eth_type = "KSZ8081"; + break; + + case ETHERNET_TYPE_KSZ8081RNA: + eth_type = "KSZ8081RNA"; + break; +#endif +#if CONFIG_ETH_SPI_ETHERNET_W5500 + case ETHERNET_TYPE_W5500: + eth_type = "W5500"; + break; +#endif +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + case ETHERNET_TYPE_DM9051: + eth_type = "DM9051"; + break; +#endif +#ifdef USE_ETHERNET_OPENETH + case ETHERNET_TYPE_OPENETH: + eth_type = "OPENETH"; + break; +#endif +#ifdef USE_ETHERNET_LAN8670 + case ETHERNET_TYPE_LAN8670: + eth_type = "LAN8670"; + break; +#endif + + default: + eth_type = "Unknown"; + break; + } + + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Connected: %s", + YESNO(this->is_connected())); + this->dump_connect_params_(); +#ifdef USE_ETHERNET_SPI + ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" + " MISO Pin: %u\n" + " MOSI Pin: %u\n" + " CS Pin: %u", + this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + if (this->polling_interval_ != 0) { + ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_); + } else +#endif + { + ESP_LOGCONFIG(TAG, " IRQ Pin: %d", this->interrupt_pin_); + } + ESP_LOGCONFIG(TAG, + " Reset Pin: %d\n" + " Clock Speed: %d MHz", + this->reset_pin_, this->clock_speed_ / 1000000); +#else + if (this->power_pin_ != -1) { + ESP_LOGCONFIG(TAG, " Power Pin: %u", this->power_pin_); + } + ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" + " MDC Pin: %u\n" + " MDIO Pin: %u\n" + " PHY addr: %u", + this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); +#endif + ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +} + +network::IPAddresses EthernetComponent::get_ip_addresses() { + network::IPAddresses addresses; + esp_netif_ip_info_t ip; + esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err)); + // TODO: do something smarter + // return false; + } else { + addresses[0] = network::IPAddress(&ip.ip); + } +#if USE_NETWORK_IPV6 + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + uint8_t count = 0; + count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); + assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); + assert(count < addresses.size()); + for (int i = 0; i < count; i++) { + addresses[i + 1] = network::IPAddress(&if_ip6s[i]); + } +#endif /* USE_NETWORK_IPV6 */ + + return addresses; +} + +network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { + LwIPLock lock; + const ip_addr_t *dns_ip = dns_getserver(num); + return dns_ip; +} + +void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event, void *event_data) { + const char *event_name; + + switch (event) { + case ETHERNET_EVENT_START: + event_name = "ETH started"; + global_eth_component->started_ = true; + global_eth_component->enable_loop_soon_any_context(); + break; + case ETHERNET_EVENT_STOP: + event_name = "ETH stopped"; + global_eth_component->started_ = false; + global_eth_component->connected_ = false; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes + break; + case ETHERNET_EVENT_CONNECTED: + event_name = "ETH connected"; + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP) + if (global_eth_component->manual_ip_.has_value()) { + global_eth_component->notify_ip_state_listeners_(); + } +#endif + break; + case ETHERNET_EVENT_DISCONNECTED: + event_name = "ETH disconnected"; + global_eth_component->connected_ = false; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes + break; + default: + return; + } + + ESP_LOGV(TAG, "[Ethernet event] %s (num=%" PRId32 ")", event_name, event); +} + +void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, + void *event_data) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data; + const esp_netif_ip_info_t *ip_info = &event->ip_info; + ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip)); + global_eth_component->got_ipv4_address_ = true; +#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) + global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#else + global_eth_component->connected_ = true; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#endif /* USE_NETWORK_IPV6 */ +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + global_eth_component->notify_ip_state_listeners_(); +#endif +} + +#if USE_NETWORK_IPV6 +void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, + void *event_data) { + ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; + ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); + global_eth_component->ipv6_count_ += 1; +#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) + global_eth_component->connected_ = + global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#else + global_eth_component->connected_ = global_eth_component->got_ipv4_address_; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#endif +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + global_eth_component->notify_ip_state_listeners_(); +#endif +} +#endif /* USE_NETWORK_IPV6 */ + +void EthernetComponent::finish_connect_() { +#if USE_NETWORK_IPV6 + // Retry IPv6 link-local setup if it failed during initial connect + // This handles the case where min_ipv6_addr_count is NOT set (or is 0), + // allowing us to reach CONNECTED state with just IPv4. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready: + // - Bootup timing issues (#10281) + // - Cable unplugged/network interruption (#10705) + // We can now retry since we're in CONNECTED state and the interface is definitely up. + if (!this->ipv6_setup_done_) { + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); + } + // Always set the flag to prevent continuous retries + // If IPv6 setup fails here with the interface up and stable, it's + // likely a persistent issue (IPv6 disabled at router, hardware + // limitation, etc.) that won't be resolved by further retries. + // The device continues to work with IPv4. + this->ipv6_setup_done_ = true; + } +#endif /* USE_NETWORK_IPV6 */ +} + +void EthernetComponent::start_connect_() { + global_eth_component->got_ipv4_address_ = false; +#if USE_NETWORK_IPV6 + global_eth_component->ipv6_count_ = 0; + this->ipv6_setup_done_ = false; +#endif /* USE_NETWORK_IPV6 */ + this->connect_begin_ = millis(); + this->status_set_warning(LOG_STR("waiting for IP configuration")); + + esp_err_t err; + err = esp_netif_set_hostname(this->eth_netif_, App.get_name().c_str()); + if (err != ERR_OK) { + ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); + } + + esp_netif_ip_info_t info; +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + info.ip = this->manual_ip_->static_ip; + info.gw = this->manual_ip_->gateway; + info.netmask = this->manual_ip_->subnet; + } else +#endif + { + info.ip.addr = 0; + info.gw.addr = 0; + info.netmask.addr = 0; + } + + esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT; + + err = esp_netif_dhcpc_get_status(this->eth_netif_, &status); + ESPHL_ERROR_CHECK(err, "DHCPC Get Status Failed!"); + + ESP_LOGV(TAG, "DHCP Client Status: %d", status); + + err = esp_netif_dhcpc_stop(this->eth_netif_); + if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { + ESPHL_ERROR_CHECK(err, "DHCPC stop error"); + } + + err = esp_netif_set_ip_info(this->eth_netif_, &info); + ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); + +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + LwIPLock lock; + if (this->manual_ip_->dns1.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns1; + dns_setserver(0, &d); + } + if (this->manual_ip_->dns2.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns2; + dns_setserver(1, &d); + } + } else +#endif + { + err = esp_netif_dhcpc_start(this->eth_netif_); + if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { + ESPHL_ERROR_CHECK(err, "DHCPC start error"); + } + } +#if USE_NETWORK_IPV6 + // Attempt to create IPv6 link-local address + // We MUST attempt this here, not just in finish_connect_(), because with + // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. + // However, this may fail with ESP_FAIL if the interface is not up yet: + // - At bootup when link isn't ready (#10281) + // - After disconnection/cable unplugged (#10705) + // We'll retry in finish_connect_() if it fails here. + err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err != ESP_OK) { + if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { + // This is a programming error, not a transient failure + ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); + } else { + // ESP_FAIL means the interface isn't up yet + // This is expected and non-fatal, happens in multiple scenarios: + // - During reconnection after network interruptions (#10705) + // - At bootup when the link isn't ready yet (#10281) + // We'll retry once we reach CONNECTED state and the interface is up + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); + // Don't mark component as failed - this is a transient error + } + } +#endif /* USE_NETWORK_IPV6 */ + + this->connect_begin_ = millis(); + this->status_set_warning(); +} + +void EthernetComponent::dump_connect_params_() { + esp_netif_ip_info_t ip; + esp_netif_get_ip_info(this->eth_netif_, &ip); + const ip_addr_t *dns_ip1; + const ip_addr_t *dns_ip2; + { + LwIPLock lock; + dns_ip1 = dns_getserver(0); + dns_ip2 = dns_getserver(1); + } + + // Use stack buffers for IP address formatting to avoid heap allocations + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + " IP Address: %s\n" + " Hostname: '%s'\n" + " Subnet: %s\n" + " Gateway: %s\n" + " DNS1: %s\n" + " DNS2: %s\n" + " MAC Address: %s\n" + " Is Full Duplex: %s\n" + " Link Speed: %u", + network::IPAddress(&ip.ip).str_to(ip_buf), App.get_name().c_str(), + network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), + network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), + this->get_eth_mac_address_pretty_into_buffer(mac_buf), + YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); + +#if USE_NETWORK_IPV6 + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + uint8_t count = 0; + count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); + assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); + for (int i = 0; i < count; i++) { + ESP_LOGCONFIG(TAG, " IPv6: " IPV6STR, IPV62STR(if_ip6s[i])); + } +#endif /* USE_NETWORK_IPV6 */ +} + +#ifdef USE_ETHERNET_SPI +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } +void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } +void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } +void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } +void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } +void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT +void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } +#endif +#else +void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } +void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } +void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } +void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } +void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } +#endif + +void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + esp_err_t err; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); + ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); +} + +std::string EthernetComponent::get_eth_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); +} + +const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( + std::span buf) { + uint8_t mac[6]; + get_eth_mac_address_raw(mac); + format_mac_addr_upper(mac, buf.data()); + return buf.data(); +} + +eth_duplex_t EthernetComponent::get_duplex_mode() { + esp_err_t err; + eth_duplex_t duplex_mode; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); + ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_DUPLEX_MODE error", ETH_DUPLEX_HALF); + return duplex_mode; +} + +eth_speed_t EthernetComponent::get_link_speed() { + esp_err_t err; + eth_speed_t speed; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); + ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_SPEED error", ETH_SPEED_10M); + return speed; +} + +bool EthernetComponent::powerdown() { + ESP_LOGI(TAG, "Powering down ethernet PHY"); + if (this->phy_ == nullptr) { + ESP_LOGE(TAG, "Ethernet PHY not assigned"); + return false; + } + this->connected_ = false; + this->started_ = false; + // No need to enable_loop() here as this is only called during shutdown/reboot + if (this->phy_->pwrctl(this->phy_, false) != ESP_OK) { + ESP_LOGE(TAG, "Error powering down ethernet PHY"); + return false; + } + return true; +} + +#ifndef USE_ETHERNET_SPI + +#ifdef USE_ETHERNET_KSZ8081 +constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; + +void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { + esp_err_t err; + + uint32_t phy_control_2; + err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); + ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(PHY_REG_SIZE)]; +#endif + ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); + + /* + * Bit 7 is `RMII Reference Clock Select`. Default is `0`. + * KSZ8081RNA: + * 0 - clock input to XI (Pin 8) is 25 MHz for RMII - 25 MHz clock mode. + * 1 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. + * KSZ8081RND: + * 0 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. + * 1 - clock input to XI (Pin 8) is 25 MHz (driven clock only, not a crystal) for RMII - 25 MHz clock mode. + */ + if ((phy_control_2 & (1 << 7)) != (1 << 7)) { + phy_control_2 |= 1 << 7; + err = mac->write_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, phy_control_2); + ESPHL_ERROR_CHECK(err, "Write PHY Control 2 failed"); + err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); + ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); + ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", + format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); + } +} +#endif // USE_ETHERNET_KSZ8081 + +void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { + esp_err_t err; + +#ifdef USE_ETHERNET_RTL8201 + constexpr uint8_t eth_phy_psr_reg_addr = 0x1F; + if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { + ESP_LOGD(TAG, "Select PHY Register Page: 0x%02" PRIX32, register_data.page); + err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, register_data.page); + ESPHL_ERROR_CHECK(err, "Select PHY Register page failed"); + } +#endif + + ESP_LOGD(TAG, "Writing PHY reg 0x%02" PRIX32 " = 0x%04" PRIX32, register_data.address, register_data.value); + err = mac->write_phy_reg(mac, this->phy_addr_, register_data.address, register_data.value); + ESPHL_ERROR_CHECK(err, "Writing PHY Register failed"); + +#ifdef USE_ETHERNET_RTL8201 + if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { + ESP_LOGD(TAG, "Select PHY Register Page 0x00"); + err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, 0x0); + ESPHL_ERROR_CHECK(err, "Select PHY Register Page 0 failed"); + } +#endif +} + +#endif + +} // namespace esphome::ethernet + +#endif // USE_ETHERNET && USE_ESP32 diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c index 963db3ff1c4..49fbe825c84 100644 --- a/esphome/components/ethernet/ethernet_helpers.c +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -1,3 +1,5 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 #include "esp_eth_mac_esp.h" // ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers @@ -8,3 +10,4 @@ eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); } #endif +#endif // USE_ESP32 diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp index 72ce9c86e22..15ef6a1f205 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp @@ -1,7 +1,7 @@ #include "ethernet_info_text_sensor.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 +#ifdef USE_ETHERNET namespace esphome::ethernet_info { @@ -49,4 +49,4 @@ void MACAddressEthernetInfo::dump_config() { LOG_TEXT_SENSOR("", "EthernetInfo M } // namespace esphome::ethernet_info -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 912a39a83f3..11002d51bad 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -4,7 +4,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/ethernet/ethernet_component.h" -#ifdef USE_ESP32 +#ifdef USE_ETHERNET namespace esphome::ethernet_info { @@ -50,4 +50,4 @@ class MACAddressEthernetInfo final : public Component, public text_sensor::TextS } // namespace esphome::ethernet_info -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 073170aafbf..75e63b1462b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -278,6 +278,12 @@ #define USE_ETHERNET_JL1101 #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_LAN8670 +#define USE_ETHERNET_SPI +#define USE_ETHERNET_SPI_POLLING_SUPPORT +#define USE_ETHERNET_OPENETH +#define CONFIG_ETH_SPI_ETHERNET_W5500 1 +#define CONFIG_ETH_SPI_ETHERNET_DM9051 1 +#define CONFIG_ETH_USE_ESP32_EMAC 1 #define USE_ETHERNET_MANUAL_IP #define USE_ETHERNET_IP_STATE_LISTENERS #define USE_ETHERNET_CONNECT_TRIGGER From ccb467b219409fc8d5632f81af531c423437fba7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 18:14:41 -1000 Subject: [PATCH 136/279] [fastled] Include esp_lcd IDF component for ESP32-S3 compatibility (#14839) --- esphome/components/fastled_base/__init__.py | 5 +++++ tests/components/fastled_clockless/test.esp32-s3-ard.yaml | 1 + 2 files changed, 6 insertions(+) create mode 100644 tests/components/fastled_clockless/test.esp32-s3-ard.yaml diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index 11e8423258c..c944e8a930c 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_OUTPUT_ID, CONF_RGB_ORDER, ) +from esphome.core import CORE CODEOWNERS = ["@OttoWinter"] fastled_base_ns = cg.esphome_ns.namespace("fastled_base") @@ -41,5 +42,9 @@ async def new_fastled_light(config): cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) cg.add_library("fastled/FastLED", "3.9.16") + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + include_builtin_idf_component("esp_lcd") await light.register_light(var, config) return var diff --git a/tests/components/fastled_clockless/test.esp32-s3-ard.yaml b/tests/components/fastled_clockless/test.esp32-s3-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/fastled_clockless/test.esp32-s3-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 9948adc6a004c2961f7c578f5fd329a030784bb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 18:15:01 -1000 Subject: [PATCH 137/279] [runtime_image] Add esp-dsp dependency for JPEGDEC SIMD on ESP32 (#14840) Co-authored-by: Copilot Autofix powered by AI <175728472+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/runtime_image/__init__.py | 8 ++++++++ esphome/idf_component.yml | 2 ++ .../online_image/test.esp32-s3-ard.yaml | 19 +++++++++++++++++++ .../online_image/test.esp32-s3-idf.yaml | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 tests/components/online_image/test.esp32-s3-ard.yaml create mode 100644 tests/components/online_image/test.esp32-s3-idf.yaml diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 7c22bfc9d19..3ae35cc5f17 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -11,6 +11,7 @@ from esphome.components.image import ( ) import esphome.config_validation as cv from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE +from esphome.core import CORE AUTO_LOAD = ["image"] CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"] @@ -75,6 +76,13 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + # JPEGDEC uses ESP32-S3 SIMD optimizations (guarded by board-level + # ARDUINO_ESP32S3_DEV define) that require esp-dsp headers. + # On Arduino this overwrites the stub; on IDF it adds the component. + add_idf_component(name="espressif/esp-dsp", ref="1.7.1") class PNGFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1e2d452919c..59876e8b3da 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -5,6 +5,8 @@ dependencies: version: 2.0.3 esphome/micro-opus: version: 0.3.5 + espressif/esp-dsp: + version: "1.7.1" espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml new file mode 100644 index 00000000000..9116fd86e09 --- /dev/null +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -0,0 +1,19 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-ard.yaml + +<<: !include common.yaml + +http_request: + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(online_rgba_image)); diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..f219f71ee25 --- /dev/null +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -0,0 +1,19 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +<<: !include common.yaml + +http_request: + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(online_rgba_image)); From c09edb94c1984add3e28fcbe56003800d588ae15 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 16 Mar 2026 00:04:07 -0500 Subject: [PATCH 138/279] [tinyusb] Fix regression from bump to 2.x in #14796 (#14848) --- .../components/tinyusb/tinyusb_component.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 7f8fea52647..2ec696c3e43 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -2,6 +2,7 @@ #include "tinyusb_component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "tinyusb_default_config.h" namespace esphome::tinyusb { @@ -15,19 +16,19 @@ void TinyUSB::setup() { this->string_descriptor_[SERIAL_NUMBER] = mac_addr_buf; } - this->tusb_cfg_ = { - .port = TINYUSB_PORT_FULL_SPEED_0, - .phy = {.skip_setup = false}, - .descriptor = - { - .device = &this->usb_descriptor_, - .string = this->string_descriptor_, - .string_count = SIZE, - }, + // Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates. + this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); + this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; + this->tusb_cfg_.phy.skip_setup = false; + this->tusb_cfg_.descriptor = { + .device = &this->usb_descriptor_, + .string = this->string_descriptor_, + .string_count = SIZE, }; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { + ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); } } From 1183ef825b6d1fbb316b1df150d846bac0c09aeb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:09:55 -0400 Subject: [PATCH 139/279] [usb_host] Fix ESP-IDF 6.0 compatibility for external USB host component (#14844) --- esphome/components/usb_host/__init__.py | 5 +++++ esphome/idf_component.yml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index e4c11be4894..5eb0371e5c5 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -4,7 +4,9 @@ from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_component, add_idf_sdkconfig_option, + idf_version, only_on_variant, ) import esphome.config_validation as cv @@ -64,6 +66,9 @@ async def register_usb_client(config): async def to_code(config: ConfigType) -> None: + # IDF 6.0 moved USB host to an external component + if idf_version() >= cv.Version(6, 0, 0): + add_idf_component(name="espressif/usb", ref="1.3.0") add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024) if config.get(CONF_ENABLE_HUBS): add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 59876e8b3da..c949c3b0266 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -43,5 +43,9 @@ dependencies: version: "1.0.0" rules: - if: "idf_version >=6.0.0" + espressif/usb: + version: "1.3.0" + rules: + - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" esp32async/asynctcp: version: 3.4.91 From e1252e32d146d547c54edfd54d69e4b91c65f5ff Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:10:30 -0400 Subject: [PATCH 140/279] [deep_sleep] Fix ESP-IDF 6.0 GPIO wakeup API rename (#14846) --- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 79c34f627a9..4f4d262d30d 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -3,6 +3,7 @@ #include "driver/gpio.h" #include "deep_sleep_component.h" #include "esphome/core/log.h" +#include namespace esphome { namespace deep_sleep { @@ -26,7 +27,7 @@ namespace deep_sleep { // - ext0: Single pin wakeup using RTC GPIO (esp_sleep_enable_ext0_wakeup) // - ext1: Multiple pin wakeup (esp_sleep_enable_ext1_wakeup) // - Touch: Touch pad wakeup (esp_sleep_enable_touchpad_wakeup) -// - GPIO wakeup: GPIO wakeup for RTC pins (esp_deep_sleep_enable_gpio_wakeup) +// - GPIO wakeup: GPIO wakeup for RTC pins static const char *const TAG = "deep_sleep"; @@ -135,8 +136,13 @@ void DeepSleepComponent::deep_sleep_() { } // Internal pullup/pulldown resistors are enabled automatically, when // ESP_SLEEP_GPIO_ENABLE_INTERNAL_RESISTORS is set (by default it is) - esp_deep_sleep_enable_gpio_wakeup(1 << this->wakeup_pin_->get_pin(), +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << this->wakeup_pin_->get_pin(), + static_cast(level)); +#else + esp_deep_sleep_enable_gpio_wakeup(1ULL << this->wakeup_pin_->get_pin(), static_cast(level)); +#endif } #endif From 2ee0df1da3d5dc2e025cbe1ae5b352c523bc61d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:43:38 +0000 Subject: [PATCH 141/279] Bump aioesphomeapi from 44.5.1 to 44.5.2 (#14849) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e634bcb1046..da95dd5a13d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.5.1 +aioesphomeapi==44.5.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 414182fe6d9ca2173631843338c690c9e304ea5d Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 16 Mar 2026 08:08:05 +0100 Subject: [PATCH 142/279] [ble_nus] fix uart debug (#14850) --- esphome/components/ble_nus/ble_nus.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index d0d37dbf1cb..2f60f814718 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -67,14 +67,14 @@ bool BLENUS::read_array(uint8_t *data, size_t len) { // First, use the peek buffer if available if (this->has_peek_) { +#ifdef USE_UART_DEBUGGER + this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); +#endif data[0] = this->peek_buffer_; this->has_peek_ = false; data++; if (--len == 0) { // Decrement len first, then check it... -#ifdef USE_UART_DEBUGGER - this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); -#endif - return true; // No more to read + return true; // No more to read } } From f86bb2bdb045e97392caa4ef6ce35048bd3d4d93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 02:02:59 -1000 Subject: [PATCH 143/279] [ethernet] Add IDF 6.0 registry component dependencies (#14847) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 69 ++++++++++++++++++- .../components/ethernet/esp_eth_phy_jl1101.c | 3 +- .../components/ethernet/ethernet_component.h | 3 +- .../ethernet/ethernet_component_esp32.cpp | 59 +++++++++++++--- esphome/core/defines.h | 2 + esphome/idf_component.yml | 28 ++++++++ 6 files changed, 149 insertions(+), 15 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 83bef4d91cb..e1ceefeacd6 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import logging from esphome import automation, pins @@ -35,6 +36,7 @@ from esphome.const import ( CONF_VALUE, KEY_CORE, KEY_FRAMEWORK_VERSION, + KEY_NATIVE_IDF, Platform, PlatformFramework, ) @@ -53,6 +55,9 @@ LOGGER = logging.getLogger(__name__) # Key for tracking IP state listener count in CORE.data ETHERNET_IP_STATE_LISTENERS_KEY = "ethernet_ip_state_listeners" +# Key for tracking configured ethernet type +ETHERNET_TYPE_KEY = "ethernet_type" +KEY_ETHERNET = "ethernet" def request_ethernet_ip_state_listener() -> None: @@ -126,9 +131,32 @@ _PHY_TYPE_TO_DEFINE = { "JL1101": "USE_ETHERNET_JL1101", "KSZ8081": "USE_ETHERNET_KSZ8081", "KSZ8081RNA": "USE_ETHERNET_KSZ8081", + "W5500": "USE_ETHERNET_W5500", + "DM9051": "USE_ETHERNET_DM9051", "LAN8670": "USE_ETHERNET_LAN8670", } + +@dataclass(frozen=True) +class IDFRegistryComponent: + """An ESP-IDF component from the Espressif Component Registry.""" + + name: str + version: str + + +# IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry. +_IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { + "LAN8720": IDFRegistryComponent("espressif/lan87xx", "1.0.0"), + "RTL8201": IDFRegistryComponent("espressif/rtl8201", "1.0.1"), + "DP83848": IDFRegistryComponent("espressif/dp83848", "1.0.0"), + "IP101": IDFRegistryComponent("espressif/ip101", "1.0.0"), + "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), + "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), + "W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"), + "DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"), +} + SPI_ETHERNET_TYPES = ["W5500", "DM9051"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) @@ -406,6 +434,7 @@ async def to_code(config): cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: cg.add_define("USE_ETHERNET_MANUAL_IP") @@ -439,6 +468,7 @@ async def _to_code_esp32(var, config): from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, + idf_version, include_builtin_idf_component, ) @@ -459,7 +489,11 @@ async def _to_code_esp32(var, config): cg.add_define("USE_ETHERNET_SPI") add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) - add_idf_sdkconfig_option(f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True) + # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 + if idf_version() < cv.Version(6, 0, 0): + add_idf_sdkconfig_option( + f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True + ) elif config[CONF_TYPE] == "OPENETH": cg.add_define("USE_ETHERNET_OPENETH") add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) @@ -491,6 +525,12 @@ async def _to_code_esp32(var, config): # Add LAN867x 10BASE-T1S PHY support component add_idf_component(name="espressif/lan867x", ref="2.0.0") + # IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry + if idf_version() >= cv.Version(6, 0, 0) and ( + component := _IDF6_ETHERNET_COMPONENTS.get(config[CONF_TYPE]) + ): + add_idf_component(name=component.name, ref=component.version) + def _final_validate_rmii_pins(config: ConfigType) -> None: """Validate that RMII pins are not used by other components.""" @@ -565,11 +605,36 @@ async def final_step(): cg.add_define("ESPHOME_ETHERNET_IP_STATE_LISTENERS", ip_state_count) -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_platform_filter = filter_source_files_from_platform( { "ethernet_component_esp32.cpp": { PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, + "esp_eth_phy_jl1101.c": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, } ) + + +def _filter_source_files() -> list[str]: + excluded = _platform_filter() + eth_data = CORE.data.get(KEY_ETHERNET, {}) + eth_type = eth_data.get(ETHERNET_TYPE_KEY) + # Only compile the custom JL1101 driver when JL1101 is configured + # and pioarduino doesn't have it builtin (IDF 5.4.2 to 5.x) + if eth_type != "JL1101": + excluded.append("esp_eth_phy_jl1101.c") + elif CORE.is_esp32 and not CORE.data.get(KEY_NATIVE_IDF, False): + from esphome.components.esp32 import idf_version + + # pioarduino has JL1101 builtin on IDF 5.4.2-5.x; exclude custom driver + # to avoid shadowing. Native IDF builds always need the custom driver. + if cv.Version(5, 4, 2) <= idf_version() < cv.Version(6, 0, 0): + excluded.append("esp_eth_phy_jl1101.c") + return excluded + + +FILTER_SOURCE_FILES = _filter_source_files diff --git a/esphome/components/ethernet/esp_eth_phy_jl1101.c b/esphome/components/ethernet/esp_eth_phy_jl1101.c index b81d8227d45..46671a2dd06 100644 --- a/esphome/components/ethernet/esp_eth_phy_jl1101.c +++ b/esphome/components/ethernet/esp_eth_phy_jl1101.c @@ -29,7 +29,8 @@ #include "esp_rom_sys.h" #include "esp_idf_version.h" -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \ + ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) static const char *TAG = "jl1101"; #define PHY_CHECK(a, str, goto_tag, ...) \ diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 80038d50ecb..cc73c01df4b 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -239,7 +239,8 @@ class EthernetComponent : public Component { extern EthernetComponent *global_eth_component; #ifdef USE_ESP32 -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \ + ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) extern "C" esp_eth_phy_t *esp_eth_phy_new_jl1101(const eth_phy_config_t *config); #endif #endif // USE_ESP32 diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index ac8680f3e13..fb69a901aac 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,36 @@ #include #include "esp_event.h" +// IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry; +// they are no longer included via esp_eth.h and need explicit includes. +// On IDF 5.x these headers don't exist as standalone files. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef USE_ETHERNET_LAN8720 +#include "esp_eth_phy_lan87xx.h" +#endif +#ifdef USE_ETHERNET_RTL8201 +#include "esp_eth_phy_rtl8201.h" +#endif +#ifdef USE_ETHERNET_DP83848 +#include "esp_eth_phy_dp83848.h" +#endif +#ifdef USE_ETHERNET_IP101 +#include "esp_eth_phy_ip101.h" +#endif +#ifdef USE_ETHERNET_KSZ8081 +#include "esp_eth_phy_ksz80xx.h" +#endif +#ifdef USE_ETHERNET_W5500 +#include "esp_eth_mac_w5500.h" +#include "esp_eth_phy_w5500.h" +#endif +#ifdef USE_ETHERNET_DM9051 +#include "esp_eth_mac_dm9051.h" +#include "esp_eth_phy_dm9051.h" +#endif +#endif // ESP_IDF_VERSION >= 6.0.0 + +// LAN867x header exists on all IDF versions (external component since IDF 5.3) #ifdef USE_ETHERNET_LAN8670 #include "esp_eth_phy_lan867x.h" #endif @@ -164,21 +194,21 @@ void EthernetComponent::setup() { .post_cb = nullptr, }; -#if CONFIG_ETH_SPI_ETHERNET_W5500 +#ifdef USE_ETHERNET_W5500 eth_w5500_config_t w5500_config = ETH_W5500_DEFAULT_CONFIG(host, &devcfg); #endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 +#ifdef USE_ETHERNET_DM9051 eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); #endif -#if CONFIG_ETH_SPI_ETHERNET_W5500 +#ifdef USE_ETHERNET_W5500 w5500_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; #endif #endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 +#ifdef USE_ETHERNET_DM9051 dm9051_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT dm9051_config.poll_period_ms = this->polling_interval_; @@ -204,7 +234,8 @@ void EthernetComponent::setup() { esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; #endif esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t) this->clk_pin_; + esp32_emac_config.clock_config.rmii.clock_gpio = + static_cast(this->clk_pin_); esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); #endif @@ -213,7 +244,11 @@ void EthernetComponent::setup() { #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: { phy_config.autonego_timeout_ms = 1000; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + this->phy_ = esp_eth_phy_new_generic(&phy_config); +#else this->phy_ = esp_eth_phy_new_dp83848(&phy_config); +#endif break; } #endif @@ -242,8 +277,10 @@ void EthernetComponent::setup() { break; } #endif -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) +#ifdef USE_ETHERNET_JL1101 case ETHERNET_TYPE_JL1101: { + // PlatformIO (pioarduino): builtin esp_eth_phy_new_jl1101() on all IDF versions + // Non-PlatformIO: custom ESPHome driver (esp_eth_phy_jl1101.c) this->phy_ = esp_eth_phy_new_jl1101(&phy_config); break; } @@ -263,14 +300,14 @@ void EthernetComponent::setup() { #endif #endif #ifdef USE_ETHERNET_SPI -#if CONFIG_ETH_SPI_ETHERNET_W5500 +#ifdef USE_ETHERNET_W5500 case ETHERNET_TYPE_W5500: { mac = esp_eth_mac_new_w5500(&w5500_config, &mac_config); this->phy_ = esp_eth_phy_new_w5500(&phy_config); break; } #endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 +#ifdef USE_ETHERNET_DM9051 case ETHERNET_TYPE_DM9051: { mac = esp_eth_mac_new_dm9051(&dm9051_config, &mac_config); this->phy_ = esp_eth_phy_new_dm9051(&phy_config); @@ -354,7 +391,7 @@ void EthernetComponent::dump_config() { eth_type = "IP101"; break; #endif -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) +#ifdef USE_ETHERNET_JL1101 case ETHERNET_TYPE_JL1101: eth_type = "JL1101"; break; @@ -368,12 +405,12 @@ void EthernetComponent::dump_config() { eth_type = "KSZ8081RNA"; break; #endif -#if CONFIG_ETH_SPI_ETHERNET_W5500 +#ifdef USE_ETHERNET_W5500 case ETHERNET_TYPE_W5500: eth_type = "W5500"; break; #endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 +#ifdef USE_ETHERNET_DM9051 case ETHERNET_TYPE_DM9051: eth_type = "DM9051"; break; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 75e63b1462b..513c70e17ec 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -281,6 +281,8 @@ #define USE_ETHERNET_SPI #define USE_ETHERNET_SPI_POLLING_SUPPORT #define USE_ETHERNET_OPENETH +#define USE_ETHERNET_W5500 +#define USE_ETHERNET_DM9051 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c949c3b0266..1478d9544e6 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -31,6 +31,34 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" + espressif/lan87xx: + version: "1.0.0" + rules: + - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" + espressif/rtl8201: + version: "1.0.1" + rules: + - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" + espressif/dp83848: + version: "1.0.0" + rules: + - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" + espressif/ip101: + version: "1.0.0" + rules: + - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" + espressif/ksz80xx: + version: "1.0.0" + rules: + - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" + espressif/w5500: + version: "1.0.1" + rules: + - if: "idf_version >=6.0.0" + espressif/dm9051: + version: "1.0.0" + rules: + - if: "idf_version >=6.0.0" espressif/esp_tinyusb: version: "2.1.1" rules: From 2cd93daa5e57eb22c64ba98d67f38818cf4b7957 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 07:32:58 -1000 Subject: [PATCH 144/279] [api] Optimize plaintext varint encoding and devirtualize write_protobuf_packet (#14758) --- esphome/components/api/api_frame_helper.h | 12 +++++++++--- .../components/api/api_frame_helper_noise.cpp | 8 -------- esphome/components/api/api_frame_helper_noise.h | 1 - .../api/api_frame_helper_plaintext.cpp | 17 +++++++---------- .../components/api/api_frame_helper_plaintext.h | 1 - esphome/components/api/proto.h | 14 ++++++++++---- 6 files changed, 26 insertions(+), 27 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index b2561f2b328..e78c71507c8 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -12,6 +12,7 @@ #include "esphome/components/socket/socket.h" #include "esphome/core/application.h" #include "esphome/core/log.h" +#include "proto.h" namespace esphome::api { @@ -37,8 +38,6 @@ static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1; // Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there) static constexpr size_t MAX_MESSAGES_PER_BATCH = 34; -class ProtoWriteBuffer; - // Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars) static constexpr size_t CLIENT_INFO_NAME_MAX_LEN = 32; @@ -161,7 +160,14 @@ class APIFrameHelper { this->nodelay_counter_ = 0; } } - virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { + // Resize buffer to include footer space if needed (e.g. Noise MAC) + if (frame_footer_size_) + buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); + MessageInfo msg{type, 0, + static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; + return write_protobuf_messages(buffer, std::span(&msg, 1)); + } // Write multiple protobuf messages in a single operation // messages contains (message_type, offset, length) for each message in the buffer // The buffer contains all messages with appropriate padding before each diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index f945253c89d..b635d84f168 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -450,14 +450,6 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - // Resize to include MAC space (required for Noise encryption) - buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - MessageInfo msg{type, 0, - static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); -} - APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { APIError aerr = this->check_data_state_(); if (aerr != APIError::OK) diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 83410febb26..a6b17ff3b92 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,7 +22,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 007da7ef2b1..e97b558fa39 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -235,11 +235,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = this->rx_header_parsed_type_; return APIError::OK; } -APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); -} - APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { APIError aerr = this->check_data_state_(); @@ -257,9 +252,11 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe uint16_t total_write_len = 0; for (const auto &msg : messages) { - // Calculate varint sizes for header layout - uint8_t size_varint_len = api::ProtoSize::varint(static_cast(msg.payload_size)); - uint8_t type_varint_len = api::ProtoSize::varint(static_cast(msg.message_type)); + // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead + uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE + ? 1 + : (msg.payload_size < ProtoSize::VARINT_THRESHOLD_2_BYTE ? 2 : 3); + uint8_t type_varint_len = msg.message_type < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 : 2; uint8_t total_header_len = 1 + size_varint_len + type_varint_len; // Calculate where to start writing the header @@ -281,8 +278,8 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe // // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 // [0] - 0x00 indicator byte - // [1-3] - Payload size varint (3 bytes, for sizes 16384-2097151) - // [4-5] - Message type varint (2 bytes, for types 128-32767) + // [1-3] - Payload size varint (3 bytes, for sizes 16384-65535) + // [4-5] - Message type varint (2 bytes, for types 128-16383) // [6...] - Actual payload data // // The message starts at offset + frame_header_padding_ diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 96d47e9c7bf..f8161c039d3 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -19,7 +19,6 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 814a3f44560..6752dfb9cd0 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -473,6 +473,12 @@ class ProtoDecodableMessage : public ProtoMessage { class ProtoSize { public: + // Varint encoding thresholds: values below each threshold fit in N bytes + static constexpr uint32_t VARINT_THRESHOLD_1_BYTE = 1 << 7; // 128 + static constexpr uint32_t VARINT_THRESHOLD_2_BYTE = 1 << 14; // 16384 + static constexpr uint32_t VARINT_THRESHOLD_3_BYTE = 1 << 21; // 2097152 + static constexpr uint32_t VARINT_THRESHOLD_4_BYTE = 1 << 28; // 268435456 + /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * @@ -480,7 +486,7 @@ class ProtoSize { * @return The number of bytes needed to encode the value */ static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE varint(uint32_t value) { - if (value < 128) [[likely]] + if (value < VARINT_THRESHOLD_1_BYTE) [[likely]] return 1; // Fast path: 7 bits, most common case if (__builtin_is_constant_evaluated()) return varint_wide(value); @@ -492,11 +498,11 @@ class ProtoSize { static uint32_t varint_slow(uint32_t value) __attribute__((noinline)); // Shared cascade for values >= 128 (used by both constexpr and noinline paths) static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE varint_wide(uint32_t value) { - if (value < 16384) + if (value < VARINT_THRESHOLD_2_BYTE) return 2; - if (value < 2097152) + if (value < VARINT_THRESHOLD_3_BYTE) return 3; - if (value < 268435456) + if (value < VARINT_THRESHOLD_4_BYTE) return 4; return 5; } From 7b4af76a61a37aeeeac00f747adcab4d4b742c28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 07:33:10 -1000 Subject: [PATCH 145/279] [core] Inline Mutex on all embedded platforms (#14756) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32/helpers.cpp | 6 ------ esphome/components/esp8266/helpers.cpp | 8 ++----- esphome/components/libretiny/helpers.cpp | 6 ------ esphome/components/rp2040/helpers.cpp | 7 +----- esphome/core/helpers.h | 27 ++++++++++++++++++------ 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index 051b7ce1624..76f1c59c739 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -20,12 +20,6 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } -Mutex::Mutex() { handle_ = xSemaphoreCreateMutex(); } -Mutex::~Mutex() {} -void Mutex::lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); } -bool Mutex::try_lock() { return xSemaphoreTake(this->handle_, 0) == pdTRUE; } -void Mutex::unlock() { xSemaphoreGive(this->handle_); } - // only affects the executing core // so should not be used as a mutex lock, only to get accurate timing IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } diff --git a/esphome/components/esp8266/helpers.cpp b/esphome/components/esp8266/helpers.cpp index 4a64ae181e3..aadfc311970 100644 --- a/esphome/components/esp8266/helpers.cpp +++ b/esphome/components/esp8266/helpers.cpp @@ -12,12 +12,8 @@ namespace esphome { uint32_t random_uint32() { return os_random(); } bool random_bytes(uint8_t *data, size_t len) { return os_get_random(data, len) == 0; } -// ESP8266 doesn't have mutexes, but that shouldn't be an issue as it's single-core and non-preemptive OS. -Mutex::Mutex() {} -Mutex::~Mutex() {} -void Mutex::lock() {} -bool Mutex::try_lock() { return true; } -void Mutex::unlock() {} +// ESP8266 Mutex is defined inline as a no-op in helpers.h when USE_ESP8266 (or USE_RP2040) is set, +// independent of the ESPHOME_THREAD_SINGLE thread model define. IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } diff --git a/esphome/components/libretiny/helpers.cpp b/esphome/components/libretiny/helpers.cpp index 21913e4a16d..ffbd181c54c 100644 --- a/esphome/components/libretiny/helpers.cpp +++ b/esphome/components/libretiny/helpers.cpp @@ -15,12 +15,6 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } -Mutex::Mutex() { handle_ = xSemaphoreCreateMutex(); } -Mutex::~Mutex() {} -void Mutex::lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); } -bool Mutex::try_lock() { return xSemaphoreTake(this->handle_, 0) == pdTRUE; } -void Mutex::unlock() { xSemaphoreGive(this->handle_); } - // only affects the executing core // so should not be used as a mutex lock, only to get accurate timing IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 4191c2164ad..a69b8da4806 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -35,12 +35,7 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } -// RP2040 doesn't have mutexes, but that shouldn't be an issue as it's single-core and non-preemptive OS. -Mutex::Mutex() {} -Mutex::~Mutex() {} -void Mutex::lock() {} -bool Mutex::try_lock() { return true; } -void Mutex::unlock() {} +// RP2040 Mutex is defined inline in helpers.h for RP2040/ESP8266 builds. IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); } IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c2f4cace9a9..d220626bcf3 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1866,19 +1866,34 @@ template class Parented { */ class Mutex { public: - Mutex(); Mutex(const Mutex &) = delete; + Mutex &operator=(const Mutex &) = delete; + +#if defined(USE_ESP8266) || defined(USE_RP2040) + // Single-threaded platforms: inline no-ops so the compiler eliminates all call overhead. + Mutex() = default; + ~Mutex() = default; + void lock() {} + bool try_lock() { return true; } + void unlock() {} +#elif defined(USE_ESP32) || defined(USE_LIBRETINY) + // FreeRTOS platforms: inline to avoid out-of-line call overhead. + Mutex() { handle_ = xSemaphoreCreateMutex(); } + ~Mutex() = default; + void lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); } + bool try_lock() { return xSemaphoreTake(this->handle_, 0) == pdTRUE; } + void unlock() { xSemaphoreGive(this->handle_); } + + private: + SemaphoreHandle_t handle_; +#else + Mutex(); ~Mutex(); void lock(); bool try_lock(); void unlock(); - Mutex &operator=(const Mutex &) = delete; - private: -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - SemaphoreHandle_t handle_; -#else // d-pointer to store private data on new platforms void *handle_; // NOLINT(clang-diagnostic-unused-private-field) #endif From 7131eafc09b69eb8da0e1587435a90c4bda84b98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 07:35:04 -1000 Subject: [PATCH 146/279] [logger] Reduce per-message overhead by inlining hot path helpers (#14851) --- esphome/components/logger/log_buffer.h | 31 ++++++++++++------- esphome/components/logger/logger.h | 18 ++++++++++- esphome/components/logger/logger_esp32.cpp | 17 ---------- esphome/components/logger/logger_esp32.h | 28 +++++++++++++++++ esphome/components/logger/logger_esp8266.cpp | 5 --- esphome/components/logger/logger_esp8266.h | 13 ++++++++ .../components/logger/logger_libretiny.cpp | 2 -- esphome/components/logger/logger_libretiny.h | 13 ++++++++ esphome/components/logger/logger_rp2040.cpp | 5 --- esphome/components/logger/logger_rp2040.h | 13 ++++++++ 10 files changed, 103 insertions(+), 42 deletions(-) create mode 100644 esphome/components/logger/logger_esp32.h create mode 100644 esphome/components/logger/logger_esp8266.h create mode 100644 esphome/components/logger/logger_libretiny.h create mode 100644 esphome/components/logger/logger_rp2040.h diff --git a/esphome/components/logger/log_buffer.h b/esphome/components/logger/log_buffer.h index a56276f732f..734cb14dc58 100644 --- a/esphome/components/logger/log_buffer.h +++ b/esphome/components/logger/log_buffer.h @@ -111,7 +111,12 @@ struct LogBuffer { } #endif void write_body(const char *text, uint16_t text_length) { - this->write_(text, text_length); + const uint16_t available = this->remaining_(); + const uint16_t copy_len = (text_length < available) ? text_length : available; + if (copy_len > 0) { + memcpy(this->current_(), text, copy_len); + this->pos += copy_len; + } this->finalize_(); } @@ -119,21 +124,23 @@ struct LogBuffer { bool full_() const { return this->pos >= this->size; } uint16_t remaining_() const { return this->size - this->pos; } char *current_() { return this->data + this->pos; } - void write_(const char *value, uint16_t length) { - const uint16_t available = this->remaining_(); - const uint16_t copy_len = (length < available) ? length : available; - if (copy_len > 0) { - memcpy(this->current_(), value, copy_len); - this->pos += copy_len; - } - } void finalize_() { - // Write color reset sequence - static constexpr uint16_t RESET_COLOR_LEN = sizeof(ESPHOME_LOG_RESET_COLOR) - 1; - this->write_(ESPHOME_LOG_RESET_COLOR, RESET_COLOR_LEN); + this->write_ansi_reset_(); // Null terminate this->data[this->full_() ? this->size - 1 : this->pos] = '\0'; } + // Write ANSI reset sequence inline ("\033[0m") - avoids write_() call overhead + static constexpr uint16_t ANSI_RESET_LEN = 4; // "\033[0m" + void write_ansi_reset_() { + if (this->remaining_() >= ANSI_RESET_LEN) { + char *p = this->current_(); + *p++ = '\033'; + *p++ = '['; + *p++ = '0'; + *p++ = 'm'; + this->pos += ANSI_RESET_LEN; + } + } void strip_trailing_newlines_() { while (this->pos > 0 && this->data[this->pos - 1] == '\n') this->pos--; diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 263d12b4441..8c38cadcbc5 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -233,7 +233,11 @@ class Logger final : public Component { void cdc_loop_(); #endif void process_messages_(); +#if defined(USE_HOST) || defined(USE_ZEPHYR) void write_msg_(const char *msg, uint16_t len); +#else + inline void write_msg_(const char *msg, uint16_t len); // Defined in platform-specific logger_*.h +#endif // Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator // thread_name: name of the calling thread/task, or nullptr for main task (callers already know which task they're on) @@ -366,7 +370,7 @@ class Logger final : public Component { bool non_main_task_recursion_guard_{false}; // Shared guard for all non-main tasks on LibreTiny #endif #else - bool global_recursion_guard_{false}; // Simple global recursion guard for single-task platforms + bool global_recursion_guard_{false}; // Simple global recursion guard for single-task platforms #endif // Large buffer placed last to keep frequently-accessed member offsets small @@ -498,3 +502,15 @@ class LoggerMessageTrigger final : public Triggeruart_num_, msg, len); -#endif -} - const LogString *Logger::get_uart_selection_() { switch (this->uart_) { case UART_SELECTION_UART0: diff --git a/esphome/components/logger/logger_esp32.h b/esphome/components/logger/logger_esp32.h new file mode 100644 index 00000000000..905111c7184 --- /dev/null +++ b/esphome/components/logger/logger_esp32.h @@ -0,0 +1,28 @@ +#pragma once + +#ifdef USE_ESP32 +#include "esphome/core/helpers.h" +#include + +namespace esphome::logger { + +inline void HOT Logger::write_msg_(const char *msg, uint16_t len) { +#if defined(USE_LOGGER_UART_SELECTION_USB_CDC) || defined(USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG) + // USB CDC/JTAG - single write including newline (already in buffer) + // Use fwrite to stdout which goes through VFS to USB console + // + // Note: These defines indicate the user's YAML configuration choice (hardware_uart: USB_CDC/USB_SERIAL_JTAG). + // They are ONLY defined when the user explicitly selects USB as the logger output in their config. + // This is compile-time selection, not runtime detection - if USB is configured, it's always used. + // There is no fallback to regular UART if "USB isn't connected" - that's the user's responsibility + // to configure correctly for their hardware. This approach eliminates runtime overhead. + fwrite(msg, 1, len, stdout); +#else + // Regular UART - single write including newline (already in buffer) + uart_write_bytes(this->uart_num_, msg, len); +#endif +} + +} // namespace esphome::logger + +#endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 0a3433d1321..b9507e707a7 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -28,11 +28,6 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg, uint16_t len) { - // Single write with newline already in buffer (added by caller) - this->hw_serial_->write(msg, len); -} - const LogString *Logger::get_uart_selection_() { #if defined(USE_ESP8266_LOGGER_SERIAL) if (this->uart_ == UART_SELECTION_UART0_SWAP) { diff --git a/esphome/components/logger/logger_esp8266.h b/esphome/components/logger/logger_esp8266.h new file mode 100644 index 00000000000..719c10f7bae --- /dev/null +++ b/esphome/components/logger/logger_esp8266.h @@ -0,0 +1,13 @@ +#pragma once + +#ifdef USE_ESP8266 +#include "esphome/core/helpers.h" + +namespace esphome::logger { + +// Single write with newline already in buffer (added by caller) +inline void HOT Logger::write_msg_(const char *msg, uint16_t len) { this->hw_serial_->write(msg, len); } + +} // namespace esphome::logger + +#endif diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index aab8a97abff..bc3922c436a 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -49,8 +49,6 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg, uint16_t len) { this->hw_serial_->write(msg, len); } - const LogString *Logger::get_uart_selection_() { switch (this->uart_) { case UART_SELECTION_DEFAULT: diff --git a/esphome/components/logger/logger_libretiny.h b/esphome/components/logger/logger_libretiny.h new file mode 100644 index 00000000000..7b1f174ff96 --- /dev/null +++ b/esphome/components/logger/logger_libretiny.h @@ -0,0 +1,13 @@ +#pragma once + +#ifdef USE_LIBRETINY +#include "esphome/core/helpers.h" + +namespace esphome::logger { + +// Single write with newline already in buffer (added by caller) +inline void HOT Logger::write_msg_(const char *msg, uint16_t len) { this->hw_serial_->write(msg, len); } + +} // namespace esphome::logger + +#endif diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index b7225c2a258..a0215ec9ec7 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -34,11 +34,6 @@ void Logger::pre_setup() { #endif } -void HOT Logger::write_msg_(const char *msg, uint16_t len) { - // Single write with newline already in buffer (added by caller) - this->hw_serial_->write(msg, len); -} - const LogString *Logger::get_uart_selection_() { switch (this->uart_) { case UART_SELECTION_UART0: diff --git a/esphome/components/logger/logger_rp2040.h b/esphome/components/logger/logger_rp2040.h new file mode 100644 index 00000000000..604d8b8ca6a --- /dev/null +++ b/esphome/components/logger/logger_rp2040.h @@ -0,0 +1,13 @@ +#pragma once + +#ifdef USE_RP2040 +#include "esphome/core/helpers.h" + +namespace esphome::logger { + +// Single write with newline already in buffer (added by caller) +inline void HOT Logger::write_msg_(const char *msg, uint16_t len) { this->hw_serial_->write(msg, len); } + +} // namespace esphome::logger + +#endif From 808c7b67b3ad3c85b7241bd96134b4844fb2b502 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 07:35:19 -1000 Subject: [PATCH 147/279] [core] Inline WarnIfComponentBlockingGuard::finish() into header (#14798) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/core/component.cpp | 13 ++++--------- esphome/core/component.h | 23 +++++++++++++++++++---- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 761f1bd4858..bfe9beb2723 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -510,7 +510,8 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } void PollingComponent::set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } -static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time) { +void __attribute__((noinline, cold)) +WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; if (component != nullptr) { should_warn = component->should_warn_of_blocking(blocking_time); @@ -524,10 +525,8 @@ static void __attribute__((noinline, cold)) warn_blocking(Component *component, } } -uint32_t WarnIfComponentBlockingGuard::finish() { - uint32_t curr_time = millis(); - uint32_t blocking_time = curr_time - this->started_; #ifdef USE_RUNTIME_STATS +void WarnIfComponentBlockingGuard::record_runtime_stats_() { // Use micros() for accurate sub-millisecond timing. millis() has insufficient // resolution — most components complete in microseconds but millis() only has // 1ms granularity, so results were essentially random noise. @@ -535,12 +534,8 @@ uint32_t WarnIfComponentBlockingGuard::finish() { uint32_t duration_us = micros() - this->started_us_; global_runtime_stats->record_component_time(this->component_, duration_us); } -#endif - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) { - warn_blocking(this->component_, blocking_time); - } - return curr_time; } +#endif #ifdef USE_SETUP_PRIORITY_OVERRIDE void clear_setup_priority_overrides() { diff --git a/esphome/core/component.h b/esphome/core/component.h index 1aac1c72191..5fdf23e128a 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -6,6 +6,7 @@ #include #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/optional.h" @@ -575,9 +576,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -#ifdef USE_RUNTIME_STATS -uint32_t micros(); // Forward declare for inline constructor -#endif +// millis() and micros() are available via hal.h class WarnIfComponentBlockingGuard { public: @@ -592,7 +591,18 @@ class WarnIfComponentBlockingGuard { } // Finish the timing operation and return the current time - uint32_t finish(); + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { + uint32_t curr_time = millis(); + uint32_t blocking_time = curr_time - this->started_; +#ifdef USE_RUNTIME_STATS + this->record_runtime_stats_(); +#endif + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(this->component_, blocking_time); + } + return curr_time; + } ~WarnIfComponentBlockingGuard() = default; @@ -601,7 +611,12 @@ class WarnIfComponentBlockingGuard { Component *component_; #ifdef USE_RUNTIME_STATS uint32_t started_us_; + void record_runtime_stats_(); #endif + + private: + // Cold path for blocking warning - defined in component.cpp + static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); }; // Function to clear setup priority overrides after all components are set up From db405c483e10daa21adf57c2444ba4ab48775416 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 07:35:34 -1000 Subject: [PATCH 148/279] [core] Cache errno to avoid duplicate __errno() calls (#14751) Co-authored-by: Claude Opus 4.6 --- esphome/components/api/api_frame_helper.cpp | 10 +++--- .../components/async_tcp/async_tcp_socket.cpp | 32 +++++++++++-------- .../captive_portal/dns_server_esp32_idf.cpp | 5 +-- .../components/esphome/ota/ota_esphome.cpp | 15 +++++---- .../web_server_idf/web_server_idf.cpp | 5 +-- esphome/core/application.cpp | 10 ++++-- 6 files changed, 47 insertions(+), 30 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index e432a976b0d..fbee2940226 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -113,10 +113,11 @@ APIError APIFrameHelper::loop() { // Common socket write error handling APIError APIFrameHelper::handle_socket_write_error_() { - if (errno == EWOULDBLOCK || errno == EAGAIN) { + const int err = errno; + if (err == EWOULDBLOCK || err == EAGAIN) { return APIError::WOULD_BLOCK; } - HELPER_LOG("Socket write failed with errno %d", errno); + HELPER_LOG("Socket write failed with errno %d", err); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; } @@ -278,11 +279,12 @@ APIError APIFrameHelper::init_common_() { APIError APIFrameHelper::handle_socket_read_result_(ssize_t received) { if (received == -1) { - if (errno == EWOULDBLOCK || errno == EAGAIN) { + const int err = errno; + if (err == EWOULDBLOCK || err == EAGAIN) { return APIError::WOULD_BLOCK; } state_ = State::FAILED; - HELPER_LOG("Socket read failed with errno %d", errno); + HELPER_LOG("Socket read failed with errno %d", err); return APIError::SOCKET_READ_FAILED; } else if (received == 0) { state_ = State::FAILED; diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index f64e494f5f6..e8c0f163b39 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -52,11 +52,12 @@ bool AsyncClient::connect(const char *host, uint16_t port) { connect_cb_(connect_arg_, this); return true; } - if (errno != EINPROGRESS) { - ESP_LOGE(TAG, "Connect failed: %d", errno); + const int saved_errno = errno; + if (saved_errno != EINPROGRESS) { + ESP_LOGE(TAG, "Connect failed: %d", saved_errno); close(); if (error_cb_) - error_cb_(error_arg_, this, errno); + error_cb_(error_arg_, this, saved_errno); return false; } @@ -79,11 +80,12 @@ size_t AsyncClient::write(const char *data, size_t len) { ssize_t sent = socket_->write(data, len); if (sent < 0) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGE(TAG, "Write error: %d", errno); + const int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK) { + ESP_LOGE(TAG, "Write error: %d", err); close(); if (error_cb_) - error_cb_(error_arg_, this, errno); + error_cb_(error_arg_, this, err); } return 0; } @@ -129,10 +131,11 @@ void AsyncClient::loop() { error_cb_(error_arg_, this, error); } } else if (ret < 0) { - ESP_LOGE(TAG, "Select error: %d", errno); + const int err = errno; + ESP_LOGE(TAG, "Select error: %d", err); close(); if (error_cb_) - error_cb_(error_arg_, this, errno); + error_cb_(error_arg_, this, err); } } else if (connected_) { // For connected sockets, use the Application's select() results @@ -148,11 +151,14 @@ void AsyncClient::loop() { } else if (len > 0) { if (data_cb_) data_cb_(data_arg_, this, buf, len); - } else if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Read error: %d", errno); - close(); - if (error_cb_) - error_cb_(error_arg_, this, errno); + } else { + const int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK) { + ESP_LOGW(TAG, "Read error: %d", err); + close(); + if (error_cb_) + error_cb_(error_arg_, this, err); + } } } } diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index 7b75f042419..56ad9f7176b 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -100,8 +100,9 @@ void DNSServer::process_next_request() { &client_addr_len); if (len < 0) { - if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { - ESP_LOGE(TAG, "recvfrom failed: %d", errno); + const int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK && err != EINTR) { + ESP_LOGE(TAG, "recvfrom failed: %d", err); } return; } diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d8dbe2dee2d..972d2b2b8d5 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -332,12 +332,13 @@ void ESPHomeOTAComponent::handle_data_() { size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); if (read == -1) { - if (this->would_block_(errno)) { + const int err = errno; + if (this->would_block_(err)) { // read() already waited up to SO_RCVTIMEO for data, just feed WDT App.feed_wdt(); continue; } - ESP_LOGW(TAG, "Read err %d", errno); + ESP_LOGW(TAG, "Read err %d", err); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { ESP_LOGW(TAG, "Remote closed"); @@ -426,8 +427,9 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (!this->would_block_(errno)) { - ESP_LOGW(TAG, "Read err %zu bytes, errno %d", len, errno); + const int err = errno; + if (!this->would_block_(err)) { + ESP_LOGW(TAG, "Read err %zu bytes, errno %d", len, err); return false; } } else if (read == 0) { @@ -455,8 +457,9 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (!this->would_block_(errno)) { - ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, errno); + const int err = errno; + if (!this->would_block_(err)) { + ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, err); return false; } // EWOULDBLOCK: on raw TCP writes never block, delay(1) prevents spinning diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index f18570965b7..60816fc6dd2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -74,12 +74,13 @@ int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_ // Use MSG_DONTWAIT to prevent blocking when TCP send buffer is full int ret = send(sockfd, buf, buf_len, flags | MSG_DONTWAIT); if (ret < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + const int err = errno; + if (err == EAGAIN || err == EWOULDBLOCK) { // Buffer full - retry later return HTTPD_SOCK_ERR_TIMEOUT; } // Real error - ESP_LOGD(TAG, "send error: errno %d", errno); + ESP_LOGD(TAG, "send error: errno %d", err); return HTTPD_SOCK_ERR_FAIL; } return ret; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8685bff360e..3a9e825e041 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -698,18 +698,22 @@ void Application::yield_with_select_(uint32_t delay_ms) { #endif // Process select() result: - // ret < 0: error (except EINTR which is normal) // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected - if (ret >= 0 || errno == EINTR) [[likely]] { + if (ret >= 0) [[likely]] { // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); } return; } + // ret < 0: error (EINTR is normal, anything else is unexpected) + const int err = errno; + if (err == EINTR) { + return; + } // select() error - log and fall through to delay() - ESP_LOGW(TAG, "select() failed with errno %d", errno); + ESP_LOGW(TAG, "select() failed with errno %d", err); } // No sockets registered or select() failed - use regular delay delay(delay_ms); From b14255797941f285fcb6df301b96275a4c601257 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 08:26:06 -1000 Subject: [PATCH 149/279] [ethernet] Add RP2040 W5500 Ethernet support (#14820) --- esphome/components/ethernet/__init__.py | 35 +- .../components/ethernet/ethernet_component.h | 25 ++ .../ethernet/ethernet_component_rp2040.cpp | 315 ++++++++++++++++++ esphome/components/rp2040/helpers.cpp | 23 +- .../components/socket/lwip_raw_tcp_impl.cpp | 3 +- esphome/core/defines.h | 6 + .../ethernet/common-w5500-rp2040.yaml | 18 + .../ethernet/test-w5500.rp2040-ard.yaml | 1 + 8 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 esphome/components/ethernet/ethernet_component_rp2040.cpp create mode 100644 tests/components/ethernet/common-w5500-rp2040.yaml create mode 100644 tests/components/ethernet/test-w5500.rp2040-ard.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index e1ceefeacd6..f519d79aa19 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -158,6 +158,8 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { } SPI_ETHERNET_TYPES = ["W5500", "DM9051"] +# RP2040-supported SPI ethernet types +RP2040_SPI_ETHERNET_TYPES = ["W5500"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) emac_rmii_clock_mode_t = cg.global_ns.enum("emac_rmii_clock_mode_t") @@ -273,6 +275,11 @@ def _validate(config): f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " f"on ESP32 classic and ESP32-P4, not {variant}" ) + elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_SPI_ETHERNET_TYPES: + raise cv.Invalid( + f"Only {', '.join(RP2040_SPI_ETHERNET_TYPES)} are supported on RP2040, " + f"not {config[CONF_TYPE]}" + ) return config @@ -330,18 +337,21 @@ SPI_SCHEMA = cv.All( cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLOCK_SPEED, default="26.67MHz"): cv.All( - cv.frequency, cv.int_range(int(8e6), int(80e6)) + cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All( + cv.only_on_esp32, + cv.frequency, + cv.int_range(int(8e6), int(80e6)), ), # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.only_on_esp32, cv.positive_time_period_milliseconds, cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), ), } ), ), - cv.only_on([Platform.ESP32]), + cv.only_on([Platform.ESP32, Platform.RP2040]), ) CONFIG_SCHEMA = cv.All( @@ -431,6 +441,8 @@ async def to_code(config): if CORE.is_esp32: await _to_code_esp32(var, config) + elif CORE.is_rp2040: + await _to_code_rp2040(var, config) cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) @@ -464,7 +476,7 @@ async def to_code(config): CORE.add_job(final_step) -async def _to_code_esp32(var, config): +async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, @@ -532,6 +544,20 @@ async def _to_code_esp32(var, config): add_idf_component(name=component.name, ref=component.version) +async def _to_code_rp2040(var: cg.Pvariable, config: ConfigType) -> None: + cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) + cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) + cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) + cg.add(var.set_cs_pin(config[CONF_CS_PIN])) + if CONF_INTERRUPT_PIN in config: + cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) + if CONF_RESET_PIN in config: + cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) + + cg.add_define("USE_ETHERNET_SPI") + cg.add_library("lwIP_w5500", None) + + def _final_validate_rmii_pins(config: ConfigType) -> None: """Validate that RMII pins are not used by other components.""" if not CORE.is_esp32: @@ -611,6 +637,7 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, + "ethernet_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, "esp_eth_phy_jl1101.c": { PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index cc73c01df4b..901d9bc0bb4 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -22,6 +22,10 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #endif #endif // USE_ESP32 +#ifdef USE_RP2040 +#include +#endif + namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS @@ -135,6 +139,15 @@ class EthernetComponent : public Component { #endif // USE_ETHERNET_SPI #endif // USE_ESP32 +#ifdef USE_RP2040 + void set_clk_pin(uint8_t clk_pin); + void set_miso_pin(uint8_t miso_pin); + void set_mosi_pin(uint8_t mosi_pin); + void set_cs_pin(uint8_t cs_pin); + void set_interrupt_pin(int8_t interrupt_pin); + void set_reset_pin(int8_t reset_pin); +#endif // USE_RP2040 + #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } #endif @@ -200,6 +213,18 @@ class EthernetComponent : public Component { esp_eth_phy_t *phy_{nullptr}; #endif // USE_ESP32 +#ifdef USE_RP2040 + static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls + Wiznet5500lwIP *eth_{nullptr}; + uint32_t last_link_check_{0}; + uint8_t clk_pin_; + uint8_t miso_pin_; + uint8_t mosi_pin_; + uint8_t cs_pin_; + int8_t interrupt_pin_{-1}; + int8_t reset_pin_{-1}; +#endif // USE_RP2040 + // Common members #ifdef USE_ETHERNET_MANUAL_IP optional manual_ip_{}; diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp new file mode 100644 index 00000000000..77b1a22d66c --- /dev/null +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -0,0 +1,315 @@ +#include "ethernet_component.h" + +#if defined(USE_ETHERNET) && defined(USE_RP2040) + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include "esphome/components/rp2040/gpio.h" + +#include +#include +#include + +namespace esphome::ethernet { + +static const char *const TAG = "ethernet"; + +void EthernetComponent::setup() { + // Configure SPI pins + SPI.setRX(this->miso_pin_); + SPI.setTX(this->mosi_pin_); + SPI.setSCK(this->clk_pin_); + + // Toggle reset pin if configured + if (this->reset_pin_ >= 0) { + rp2040::RP2040GPIOPin reset_pin; + reset_pin.set_pin(this->reset_pin_); + reset_pin.set_flags(gpio::FLAG_OUTPUT); + reset_pin.setup(); + reset_pin.digital_write(false); + delay(1); // NOLINT + reset_pin.digital_write(true); + delay(10); // NOLINT - wait for W5500 to initialize after reset + } + + // Create the W5500 device instance + this->eth_ = new Wiznet5500lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT + + // Set hostname before begin() so the LWIP netif gets it + this->eth_->hostname(App.get_name().c_str()); + + // Configure static IP if set (must be done before begin()) +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + IPAddress ip(this->manual_ip_->static_ip); + IPAddress gateway(this->manual_ip_->gateway); + IPAddress subnet(this->manual_ip_->subnet); + IPAddress dns1(this->manual_ip_->dns1); + IPAddress dns2(this->manual_ip_->dns2); + this->eth_->config(ip, gateway, subnet, dns1, dns2); + } +#endif + + // Begin with fixed MAC or auto-generated + bool success; + if (this->fixed_mac_.has_value()) { + success = this->eth_->begin(this->fixed_mac_->data()); + } else { + success = this->eth_->begin(); + } + + if (!success) { + ESP_LOGE(TAG, "Failed to initialize W5500 Ethernet"); + delete this->eth_; // NOLINT(cppcoreguidelines-owning-memory) + this->eth_ = nullptr; + this->mark_failed(); + return; + } + + // Make this the default interface for routing + this->eth_->setDefault(true); + + // The arduino-pico LwipIntfDev automatically handles packet processing + // via __addEthernetPacketHandler when no interrupt pin is used, + // or via GPIO interrupt when one is provided. + + // Don't set started_ here — let the link polling in loop() set it + // when the W5500 link is actually up. Setting it prematurely causes + // a "Starting → Stopped → Starting" log sequence because the W5500 + // needs time after begin() before the PHY link is ready. +} + +void EthernetComponent::loop() { + // On RP2040, we need to poll connection state since there are no events. + const uint32_t now = App.get_loop_component_start_time(); + + // Throttle link/IP polling to avoid excessive SPI transactions from linkStatus() + // which reads the W5500 PHY register via SPI on every call. + // connected() reads netif->ip_addr without LwIPLock, but this is a single + // 32-bit aligned read (atomic on ARM) — worst case is a one-iteration-stale + // value, which is benign for polling. + if (this->eth_ != nullptr && now - this->last_link_check_ >= LINK_CHECK_INTERVAL) { + this->last_link_check_ = now; + bool link_up = this->eth_->linkStatus() == LinkON; + bool has_ip = this->eth_->connected(); + + if (!link_up) { + if (this->started_) { + this->started_ = false; + this->connected_ = false; + } + } else { + if (!this->started_) { + this->started_ = true; + } + bool was_connected = this->connected_; + this->connected_ = has_ip; + if (this->connected_ && !was_connected) { +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + this->notify_ip_state_listeners_(); +#endif + } + } + } + + // State machine + switch (this->state_) { + case EthernetComponentState::STOPPED: + if (this->started_) { + ESP_LOGI(TAG, "Starting connection"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTING: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; + } else if (this->connected_) { + // connection established + ESP_LOGI(TAG, "Connected"); + this->state_ = EthernetComponentState::CONNECTED; + + this->dump_connect_params_(); + this->status_clear_warning(); +#ifdef USE_ETHERNET_CONNECT_TRIGGER + this->connect_trigger_.trigger(); +#endif + } else if (now - this->connect_begin_ > 15000) { + ESP_LOGW(TAG, "Connecting failed; reconnecting"); + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTED: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else if (!this->connected_) { + ESP_LOGW(TAG, "Connection lost; reconnecting"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else { + this->finish_connect_(); + } + break; + } +} + +void EthernetComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Type: W5500\n" + " Connected: %s\n" + " CLK Pin: %u\n" + " MISO Pin: %u\n" + " MOSI Pin: %u\n" + " CS Pin: %u\n" + " IRQ Pin: %d\n" + " Reset Pin: %d", + YESNO(this->is_connected()), this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_, + this->interrupt_pin_, this->reset_pin_); + this->dump_connect_params_(); +} + +network::IPAddresses EthernetComponent::get_ip_addresses() { + network::IPAddresses addresses; + if (this->eth_ != nullptr) { + LwIPLock lock; + addresses[0] = network::IPAddress(this->eth_->localIP()); + } + return addresses; +} + +network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { + LwIPLock lock; + const ip_addr_t *dns_ip = dns_getserver(num); + return dns_ip; +} + +void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (this->eth_ != nullptr) { + this->eth_->macAddress(mac); + } else { + memset(mac, 0, 6); + } +} + +std::string EthernetComponent::get_eth_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); +} + +const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( + std::span buf) { + uint8_t mac[6]; + get_eth_mac_address_raw(mac); + format_mac_addr_upper(mac, buf.data()); + return buf.data(); +} + +eth_duplex_t EthernetComponent::get_duplex_mode() { + // W5500 is always full duplex + return ETH_DUPLEX_FULL; +} + +eth_speed_t EthernetComponent::get_link_speed() { + // W5500 is always 100Mbps + return ETH_SPEED_100M; +} + +bool EthernetComponent::powerdown() { + ESP_LOGI(TAG, "Powering down ethernet"); + if (this->eth_ != nullptr) { + this->eth_->end(); + } + this->connected_ = false; + this->started_ = false; + return true; +} + +void EthernetComponent::start_connect_() { + this->got_ipv4_address_ = false; + this->connect_begin_ = millis(); + this->status_set_warning(LOG_STR("waiting for IP configuration")); + + // Hostname is already set in setup() via LwipIntf::setHostname() + +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + // Static IP was already configured before begin() in setup() + // Set DNS servers + LwIPLock lock; + if (this->manual_ip_->dns1.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns1; + dns_setserver(0, &d); + } + if (this->manual_ip_->dns2.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns2; + dns_setserver(1, &d); + } + } +#endif +} + +void EthernetComponent::finish_connect_() { + // No additional work needed on RP2040 for now + // IPv6 link-local could be added here in the future +} + +void EthernetComponent::dump_connect_params_() { + if (this->eth_ == nullptr) { + return; + } + + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + + // Copy all lwIP state under the lock to avoid races with IRQ callbacks + ip_addr_t ip_addr, netmask, gw, dns1_addr, dns2_addr; + { + LwIPLock lock; + auto *netif = this->eth_->getNetIf(); + ip_addr = netif->ip_addr; + netmask = netif->netmask; + gw = netif->gw; + dns1_addr = *dns_getserver(0); + dns2_addr = *dns_getserver(1); + } + ESP_LOGCONFIG(TAG, + " IP Address: %s\n" + " Hostname: '%s'\n" + " Subnet: %s\n" + " Gateway: %s\n" + " DNS1: %s\n" + " DNS2: %s\n" + " MAC Address: %s", + network::IPAddress(&ip_addr).str_to(ip_buf), App.get_name().c_str(), + network::IPAddress(&netmask).str_to(subnet_buf), network::IPAddress(&gw).str_to(gateway_buf), + network::IPAddress(&dns1_addr).str_to(dns1_buf), network::IPAddress(&dns2_addr).str_to(dns2_buf), + this->get_eth_mac_address_pretty_into_buffer(mac_buf)); +} + +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } +void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } +void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } +void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } +void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } + +} // namespace esphome::ethernet + +#endif // USE_ETHERNET && USE_RP2040 diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index a69b8da4806..ad69192af97 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -8,6 +8,8 @@ #if defined(USE_WIFI) #include #include // For cyw43_arch_lwip_begin/end (LwIPLock) +#elif defined(USE_ETHERNET) +#include // For ethernet_arch_lwip_begin/end (LwIPLock) #endif #include #include @@ -40,18 +42,27 @@ bool random_bytes(uint8_t *data, size_t len) { IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); } IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } -// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. -// This means lwip callbacks run from a low-priority user IRQ context, not the +// On RP2040, lwip callbacks run from a low-priority user IRQ context, not the // main loop (see low_priority_irq_handler() in pico-sdk -// async_context_threadsafe_background.c). cyw43_arch_lwip_begin/end acquires the -// async_context recursive mutex to prevent IRQ callbacks from firing during -// critical sections. See esphome#10681. +// async_context_threadsafe_background.c). This applies to both WiFi (CYW43) and +// Ethernet (W5500) — both use async_context_threadsafe_background. // -// When CYW43 is not available (non-WiFi RP2040 boards), this is a no-op since +// Without locking, recv_fn() from IRQ context races with read_locked_() on the +// main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat +// assertion failures). See esphome#10681. +// +// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end. +// Both acquire the async_context recursive mutex to prevent IRQ callbacks from +// firing during critical sections. +// +// When neither WiFi nor Ethernet is configured, this is a no-op since // there's no network stack and no lwip callbacks to race with. #if defined(USE_WIFI) LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); } LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); } +#elif defined(USE_ETHERNET) +LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); } +LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); } #else LwIPLock::LwIPLock() {} LwIPLock::~LwIPLock() {} diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 96328e68c73..3bcbd880850 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -130,7 +130,8 @@ void socket_wake() { // code (CONT context) — they never preempt each other, so no locking is needed. // // esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). -// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end +// (Ethernet). On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT static const char *const TAG = "socket.lwip"; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 513c70e17ec..390ac8ddd7f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -353,6 +353,12 @@ #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE #define USE_SPI +#ifndef USE_ETHERNET +#define USE_ETHERNET +#endif +#ifndef USE_ETHERNET_SPI +#define USE_ETHERNET_SPI +#endif #endif #ifdef USE_LIBRETINY diff --git a/tests/components/ethernet/common-w5500-rp2040.yaml b/tests/components/ethernet/common-w5500-rp2040.yaml new file mode 100644 index 00000000000..78b2b952fc0 --- /dev/null +++ b/tests/components/ethernet/common-w5500-rp2040.yaml @@ -0,0 +1,18 @@ +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-w5500.rp2040-ard.yaml b/tests/components/ethernet/test-w5500.rp2040-ard.yaml new file mode 100644 index 00000000000..7953198b7e3 --- /dev/null +++ b/tests/components/ethernet/test-w5500.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-w5500-rp2040.yaml From cdf2867bafd8464d1147c66c5b5ef109b519aafb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:05:56 -0400 Subject: [PATCH 150/279] [hub75] Bump esp-hub75 to 0.3.4 (#14862) --- esphome/components/hub75/display.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 4f62ce7a94a..4cca0cea5da 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -587,7 +587,7 @@ def _build_config_struct( async def to_code(config: ConfigType) -> None: add_idf_component( name="esphome/esp-hub75", - ref="0.3.2", + ref="0.3.4", ) # Set compile-time configuration via build flags (so external library sees them) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1478d9544e6..a847e34b022 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -64,7 +64,7 @@ dependencies: rules: - if: "target in [esp32s2, esp32s3, esp32p4]" esphome/esp-hub75: - version: 0.3.2 + version: 0.3.4 rules: - if: "target in [esp32, esp32s2, esp32s3, esp32c6, esp32p4]" espressif/mqtt: From 05590a3a216dcfa56669926ed6e0d04c134bb10d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:39:26 -0400 Subject: [PATCH 151/279] [gpio][dallas_temp] Fix one_wire read64() and DS18S20 division by zero (#14866) --- esphome/components/dallas_temp/dallas_temp.cpp | 3 +++ esphome/components/gpio/one_wire/gpio_one_wire.cpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 13f2fa59bd7..f119e28e78e 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -136,6 +136,9 @@ bool DallasTemperatureSensor::check_scratch_pad_() { float DallasTemperatureSensor::get_temp_c_() { int16_t temp = (this->scratch_pad_[1] << 8) | this->scratch_pad_[0]; if ((this->address_ & 0xff) == DALLAS_MODEL_DS18S20) { + if (this->scratch_pad_[7] == 0) { + return NAN; + } return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; } switch (this->resolution_) { diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index 4191c45de15..4e2a306fc94 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -131,7 +131,7 @@ uint8_t IRAM_ATTR GPIOOneWireBus::read8() { uint64_t IRAM_ATTR GPIOOneWireBus::read64() { InterruptLock lock; uint64_t ret = 0; - for (uint8_t i = 0; i < 8; i++) { + for (uint8_t i = 0; i < 64; i++) { ret |= (uint64_t(this->read_bit_()) << i); } return ret; From c8f708c13ce39f012a049872fd32aadaff6215df Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:40:24 -0400 Subject: [PATCH 152/279] [lilygo_t5_47] Fix Y coordinate mapping and clamp touch point count (#14865) --- .../lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp index b29e4c21540..ee6c2ee4718 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp @@ -42,7 +42,7 @@ void LilygoT547Touchscreen::setup() { this->x_raw_max_ = this->display_->get_native_width(); } if (this->y_raw_max_ == this->y_raw_min_) { - this->x_raw_max_ = this->display_->get_native_height(); + this->y_raw_max_ = this->display_->get_native_height(); } } } @@ -64,6 +64,10 @@ void LilygoT547Touchscreen::update_touches() { } point = buffer[5] & 0xF; + if (point > 2) { + ESP_LOGW(TAG, "Invalid touch point count: %d", point); + point = 2; + } if (point == 1) { err = this->write_register(TOUCH_REGISTER, READ_TOUCH, 1); From 9362d9745ef22b96b8da73fb246ee05665c81739 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:41:21 -0400 Subject: [PATCH 153/279] [ci] Fix clang-tidy hash check 403 error on fork PRs (#14860) --- .github/workflows/ci-clang-tidy-hash.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 5054a622077..7905739b15e 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -40,7 +40,7 @@ jobs: echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY - - if: failure() + - if: failure() && github.event.pull_request.head.repo.full_name == github.repository name: Request changes uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: @@ -53,7 +53,7 @@ jobs: body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' }) - - if: success() + - if: success() && github.event.pull_request.head.repo.full_name == github.repository name: Dismiss review uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: From 0bbba7575714e2b2b2509e821d555fcd7b3fb768 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:42:13 -0400 Subject: [PATCH 154/279] [am43] Fix battery update throttle using wrong type (#14864) --- esphome/components/am43/sensor/am43_sensor.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 91973d8e33f..195b96a19eb 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -35,7 +35,7 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent uint8_t current_sensor_; // The AM43 often gets into a state where it spams loads of battery update // notifications. Here we will limit to no more than every 10s. - uint8_t last_battery_update_; + uint32_t last_battery_update_; }; } // namespace am43 From 2f86e48a836f02eec8858d139cb2c1dee7fb4b4b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:44:55 -0400 Subject: [PATCH 155/279] [as3935] Fix ENERGY_MASK dropping bit 4 of lightning energy MMSB (#14861) --- esphome/components/as3935/as3935.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/as3935/as3935.h b/esphome/components/as3935/as3935.h index 5dff1cb0aef..5f46dadfa8c 100644 --- a/esphome/components/as3935/as3935.h +++ b/esphome/components/as3935/as3935.h @@ -41,7 +41,7 @@ enum AS3935RegisterMasks { INT_MASK = 0xF0, THRESH_MASK = 0x0F, R_SPIKE_MASK = 0xF0, - ENERGY_MASK = 0xF0, + ENERGY_MASK = 0xE0, CAP_MASK = 0xF0, LIGHT_MASK = 0xCF, DISTURB_MASK = 0xDF, From c47f4fbc1c41948fc08c87c0f0412a83e0027f3c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:45:16 -0400 Subject: [PATCH 156/279] [core] Support both dot and dash separators in Version.parse (#14858) --- esphome/config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1eac53e9b20..32689dab27e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -314,7 +314,7 @@ class Version: @classmethod def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)-?(\w*)$", value) + match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) From 037f75e0ff453f66758df15ce7b1679ed3490679 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:57:17 -1000 Subject: [PATCH 157/279] Bump github/codeql-action from 4.32.6 to 4.33.0 (#14869) 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 1a0c54da6d6..2ef1a5af314 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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: category: "/language:${{matrix.language}}" From 5ee3e94ca1dfcb2cca8b75c565b585a3d5a27da8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:57:33 -1000 Subject: [PATCH 158/279] Bump actions/create-github-app-token from 2.2.1 to 3.0.0 (#14868) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/release.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 6376cf877e5..3b5e9f0d158 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -27,7 +27,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ed41d99c7b..4aa63f6a168 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,7 +221,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -256,7 +256,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -287,7 +287,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 80730fd012ee039acd821551c748ada8b9c01f37 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:57:53 -0400 Subject: [PATCH 159/279] [seeed_mr24hpc1] Fix frame parser length handling bugs (#14863) --- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 263603704ac..c9fe3a2e6ef 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -297,19 +297,17 @@ void MR24HPC1Component::r24_split_data_frame_(uint8_t value) { this->sg_recv_data_state_ = FRAME_DATA_LEN_H; break; case FRAME_DATA_LEN_H: - if (value <= 4) { - this->sg_data_len_ = value * 256; + if (value == 0) { this->sg_frame_buf_[4] = value; this->sg_recv_data_state_ = FRAME_DATA_LEN_L; } else { - this->sg_data_len_ = 0; this->sg_recv_data_state_ = FRAME_IDLE; ESP_LOGD(TAG, "FRAME_DATA_LEN_H ERROR value:%x", value); } break; case FRAME_DATA_LEN_L: - this->sg_data_len_ += value; - if (this->sg_data_len_ > 32) { + this->sg_data_len_ = value; + if (this->sg_data_len_ == 0 || this->sg_data_len_ > 32) { ESP_LOGD(TAG, "len=%d, FRAME_DATA_LEN_L ERROR value:%x", this->sg_data_len_, value); this->sg_data_len_ = 0; this->sg_recv_data_state_ = FRAME_IDLE; @@ -320,9 +318,8 @@ void MR24HPC1Component::r24_split_data_frame_(uint8_t value) { } break; case FRAME_DATA_BYTES: - this->sg_data_len_ -= 1; this->sg_frame_buf_[this->sg_frame_len_++] = value; - if (this->sg_data_len_ <= 0) { + if (--this->sg_data_len_ == 0) { this->sg_recv_data_state_ = FRAME_DATA_CRC; } break; From 8577c263583cacbb77c531608c8b045a7c269f69 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:03:09 -0400 Subject: [PATCH 160/279] [i2c] Handle ESP_ERR_INVALID_RESPONSE as NACK for IDF 6.0 (#14867) --- esphome/components/i2c/i2c_bus_esp_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index eaefabf75be..4aca4f0fae1 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -185,7 +185,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s jobs[num_jobs++].command = I2C_MASTER_CMD_STOP; ESP_LOGV(TAG, "Sending %zu jobs", num_jobs); esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 100); - if (err == ESP_ERR_INVALID_STATE) { + if (err == ESP_ERR_INVALID_STATE || err == ESP_ERR_INVALID_RESPONSE) { ESP_LOGV(TAG, "TX to %02X failed: not acked", address); return ERROR_NOT_ACKNOWLEDGED; } else if (err == ESP_ERR_TIMEOUT) { From c3327d0b435f5e2a736a23ca03ce5149e157e319 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:04:20 -0400 Subject: [PATCH 161/279] [i2s_audio] Fix ESP-IDF 6.0 compatibility for I2S port types (#14818) Co-authored-by: J. Nick Koston --- esphome/components/i2s_audio/__init__.py | 70 +++++++++++++++++++ esphome/components/i2s_audio/i2s_audio.cpp | 27 ------- esphome/components/i2s_audio/i2s_audio.h | 13 ++-- .../i2s_audio/microphone/__init__.py | 4 +- .../microphone/i2s_audio_microphone.cpp | 21 ------ 5 files changed, 80 insertions(+), 55 deletions(-) delete mode 100644 esphome/components/i2s_audio/i2s_audio.cpp diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 65b09b93f6c..977a239497a 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass, field + from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( @@ -26,6 +28,9 @@ CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] MULTI_CONF = True +CONF_PDM = "pdm" +CONF_ADC_TYPE = "adc_type" + CONF_I2S_DOUT_PIN = "i2s_dout_pin" CONF_I2S_DIN_PIN = "i2s_din_pin" CONF_I2S_MCLK_PIN = "i2s_mclk_pin" @@ -254,7 +259,65 @@ CONFIG_SCHEMA = cv.All( ) +@dataclass +class I2SAudioData: + """I2S audio component state stored in CORE.data.""" + + port_map: dict[str, int] = field(default_factory=dict) + + +def _get_data() -> I2SAudioData: + if CONF_I2S_AUDIO not in CORE.data: + CORE.data[CONF_I2S_AUDIO] = I2SAudioData() + return CORE.data[CONF_I2S_AUDIO] + + +def _assign_ports() -> None: + """Assign I2S port numbers, prioritizing instances with microphone children. + + Microphones (especially PDM) require port 0 on most ESP32 variants. + This runs once and stores the mapping in CORE.data. + """ + data = _get_data() + if data.port_map: + return + + full_config = fv.full_config.get() + i2s_configs = full_config[CONF_I2S_AUDIO] + + # Find i2s_audio instances with microphones that require port 0 + # (PDM and internal ADC only work on I2S port 0) + port0_parent_id = None + for mic_config in full_config.get("microphone", []): + if CONF_I2S_AUDIO_ID not in mic_config: + continue + if mic_config.get(CONF_PDM) or mic_config.get(CONF_ADC_TYPE) == "internal": + if port0_parent_id is not None: + raise cv.Invalid( + "Only one PDM/ADC microphone is supported (requires I2S port 0)" + ) + port0_parent_id = str(mic_config[CONF_I2S_AUDIO_ID]) + + # Assign ports: port 0 parent first (if any), rest get sequential + next_port = 0 + if port0_parent_id is not None: + data.port_map[port0_parent_id] = next_port + next_port += 1 + for config in i2s_configs: + config_id = str(config[CONF_ID]) + if config_id != port0_parent_id: + data.port_map[config_id] = next_port + next_port += 1 + + def _final_validate(_): + from esphome.components.esp32 import idf_version + + if use_legacy() and idf_version() >= cv.Version(6, 0, 0): + raise cv.Invalid( + "The legacy I2S driver is not available in ESP-IDF 6.0+. " + "Set 'use_legacy: false' in i2s_audio configuration." + ) i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -263,6 +326,7 @@ def _final_validate(_): raise cv.Invalid( f"Only {I2S_PORTS[variant]} I2S audio ports are supported on {variant}" ) + _assign_ports() def use_legacy(): @@ -276,6 +340,12 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # Assign I2S port from _final_validate computed mapping + data = _get_data() + if (port := data.port_map.get(str(config[CONF_ID]))) is None: + raise ValueError(f"No I2S port assigned for {config[CONF_ID]}") + cg.add(var.set_port(port)) + # Re-enable ESP-IDF's I2S driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_i2s") diff --git a/esphome/components/i2s_audio/i2s_audio.cpp b/esphome/components/i2s_audio/i2s_audio.cpp deleted file mode 100644 index 43064498cc5..00000000000 --- a/esphome/components/i2s_audio/i2s_audio.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "i2s_audio.h" - -#ifdef USE_ESP32 - -#include "esphome/core/log.h" - -namespace esphome { -namespace i2s_audio { - -static const char *const TAG = "i2s_audio"; - -void I2SAudioComponent::setup() { - static i2s_port_t next_port_num = I2S_NUM_0; - if (next_port_num >= SOC_I2S_NUM) { - ESP_LOGE(TAG, "Too many components"); - this->mark_failed(); - return; - } - - this->port_ = next_port_num; - next_port_num = (i2s_port_t) (next_port_num + 1); -} - -} // namespace i2s_audio -} // namespace esphome - -#endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index cfccf7e01f6..f26ffddd467 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -5,6 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include #ifdef USE_I2S_LEGACY #include #else @@ -56,8 +57,6 @@ class I2SAudioOut : public I2SAudioBase {}; class I2SAudioComponent : public Component { public: - void setup() override; - #ifdef USE_I2S_LEGACY i2s_pin_config_t get_pin_config() const { return { @@ -86,13 +85,17 @@ class I2SAudioComponent : public Component { void set_mclk_pin(int pin) { this->mclk_pin_ = pin; } void set_bclk_pin(int pin) { this->bclk_pin_ = pin; } void set_lrclk_pin(int pin) { this->lrclk_pin_ = pin; } + void set_port(int port) { this->port_ = port; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + int get_port() const { return this->port_; } +#else + i2s_port_t get_port() const { return static_cast(this->port_); } +#endif void lock() { this->lock_.lock(); } bool try_lock() { return this->lock_.try_lock(); } void unlock() { this->lock_.unlock(); } - i2s_port_t get_port() const { return this->port_; } - protected: Mutex lock_; @@ -106,7 +109,7 @@ class I2SAudioComponent : public Component { int bclk_pin_{I2S_GPIO_UNUSED}; #endif int lrclk_pin_; - i2s_port_t port_{}; + int port_{}; }; } // namespace i2s_audio diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index bf583b9f818..aa15625cd78 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -13,9 +13,11 @@ from esphome.const import ( ) from .. import ( + CONF_ADC_TYPE, CONF_I2S_DIN_PIN, CONF_LEFT, CONF_MONO, + CONF_PDM, CONF_RIGHT, I2SAudioIn, i2s_audio_component_schema, @@ -29,9 +31,7 @@ CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2s_audio"] CONF_ADC_PIN = "adc_pin" -CONF_ADC_TYPE = "adc_type" CONF_CORRECT_DC_OFFSET = "correct_dc_offset" -CONF_PDM = "pdm" I2SAudioMicrophone = i2s_audio_ns.class_( "I2SAudioMicrophone", I2SAudioIn, microphone.Microphone, cg.Component diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index eb4506071e6..a17cb0d84a0 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -37,27 +37,6 @@ enum MicrophoneEventGroupBits : uint32_t { }; void I2SAudioMicrophone::setup() { -#ifdef USE_I2S_LEGACY -#if SOC_I2S_SUPPORTS_ADC - if (this->adc_) { - if (this->parent_->get_port() != I2S_NUM_0) { - ESP_LOGE(TAG, "Internal ADC only works on I2S0"); - this->mark_failed(); - return; - } - } else -#endif -#endif - { - if (this->pdm_) { - if (this->parent_->get_port() != I2S_NUM_0) { - ESP_LOGE(TAG, "PDM only works on I2S0"); - this->mark_failed(); - return; - } - } - } - this->active_listeners_semaphore_ = xSemaphoreCreateCounting(MAX_LISTENERS, MAX_LISTENERS); if (this->active_listeners_semaphore_ == nullptr) { ESP_LOGE(TAG, "Creating semaphore failed"); From f81e04b0366732793e718d3745d700969b02dd50 Mon Sep 17 00:00:00 2001 From: KamilCuk Date: Mon, 16 Mar 2026 22:30:31 +0100 Subject: [PATCH 162/279] [web_server] Fix wrong printf format specifier (#14836) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 40830196433..1dda6204fee 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -415,7 +415,7 @@ void WebServer::setup() { this->set_interval(10000, [this]() { char buf[32]; auto uptime = static_cast(millis_64() / 1000); - buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%u}", uptime); + buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, "ping", millis(), 30000); }); } From 2142bc1b767c0f0e178d8b34b0bf7d186e0529ca Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 16 Mar 2026 23:25:11 +0100 Subject: [PATCH 163/279] [mipi_rgb] Make h- and v-sync pins optional (#14870) --- esphome/components/mipi_rgb/display.py | 18 ++++++++++++------ esphome/components/mipi_rgb/mipi_rgb.cpp | 12 ++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 24988cfcf86..0aa8c56719b 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -194,8 +194,12 @@ def model_schema(config): CONF_DE_PIN, cv.UNDEFINED ): pins.internal_gpio_output_pin_schema, model.option(CONF_PCLK_PIN): pins.internal_gpio_output_pin_schema, - model.option(CONF_HSYNC_PIN): pins.internal_gpio_output_pin_schema, - model.option(CONF_VSYNC_PIN): pins.internal_gpio_output_pin_schema, + model.option( + CONF_HSYNC_PIN, cv.UNDEFINED + ): pins.internal_gpio_output_pin_schema, + model.option( + CONF_VSYNC_PIN, cv.UNDEFINED + ): pins.internal_gpio_output_pin_schema, model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, } ) @@ -307,10 +311,12 @@ async def to_code(config): cg.add(var.set_de_pin(pin)) pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) cg.add(var.set_pclk_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_HSYNC_PIN]) - cg.add(var.set_hsync_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_VSYNC_PIN]) - cg.add(var.set_vsync_pin(pin)) + if hsync_pin := config.get(CONF_HSYNC_PIN): + pin = await cg.gpio_pin_expression(hsync_pin) + cg.add(var.set_hsync_pin(pin)) + if vsync_pin := config.get(CONF_VSYNC_PIN): + pin = await cg.gpio_pin_expression(vsync_pin) + cg.add(var.set_vsync_pin(pin)) await display.register_display(var, config) if lamb := config.get(CONF_LAMBDA): diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 824ff6afe79..0b0a5344e47 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -158,8 +158,16 @@ void MipiRgb::common_setup_() { } config.data_width = data_pin_count; config.disp_gpio_num = GPIO_NUM_NC; - config.hsync_gpio_num = static_cast(this->hsync_pin_->get_pin()); - config.vsync_gpio_num = static_cast(this->vsync_pin_->get_pin()); + if (this->hsync_pin_) { + config.hsync_gpio_num = static_cast(this->hsync_pin_->get_pin()); + } else { + config.hsync_gpio_num = GPIO_NUM_NC; + } + if (this->vsync_pin_) { + config.vsync_gpio_num = static_cast(this->vsync_pin_->get_pin()); + } else { + config.vsync_gpio_num = GPIO_NUM_NC; + } if (this->de_pin_) { config.de_gpio_num = static_cast(this->de_pin_->get_pin()); } else { From 06d1498c47e78a9635333a109a089c58309f50c2 Mon Sep 17 00:00:00 2001 From: guillempages Date: Thu, 12 Mar 2026 15:15:20 +0100 Subject: [PATCH 164/279] [runtime_image] Update jpegdec lib version (#14726) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- esphome/components/runtime_image/__init__.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ff25675918b..87b4ebb2c69 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -e4b9c4b54e705d3c9400e1cdda8ba0b32634780cfa5f32271832e911bdcafe7e +8e48e836c6fc196d3da000d46eb09db243b87fe33518a74e49c8e009d756074a diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 0773a53d911..7c22bfc9d19 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -74,7 +74,7 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") - cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2") + cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") class PNGFormat(Format): diff --git a/platformio.ini b/platformio.ini index deee23d049c..3c3d62ef76a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,11 +46,11 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.11 ; api improv/Improv@1.2.4 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md From 5d5c2723b2570d06dddab60a1152903df592d5a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 18:14:41 -1000 Subject: [PATCH 165/279] [fastled] Include esp_lcd IDF component for ESP32-S3 compatibility (#14839) --- esphome/components/fastled_base/__init__.py | 5 +++++ tests/components/fastled_clockless/test.esp32-s3-ard.yaml | 1 + 2 files changed, 6 insertions(+) create mode 100644 tests/components/fastled_clockless/test.esp32-s3-ard.yaml diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index 11e8423258c..c944e8a930c 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_OUTPUT_ID, CONF_RGB_ORDER, ) +from esphome.core import CORE CODEOWNERS = ["@OttoWinter"] fastled_base_ns = cg.esphome_ns.namespace("fastled_base") @@ -41,5 +42,9 @@ async def new_fastled_light(config): cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) cg.add_library("fastled/FastLED", "3.9.16") + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + include_builtin_idf_component("esp_lcd") await light.register_light(var, config) return var diff --git a/tests/components/fastled_clockless/test.esp32-s3-ard.yaml b/tests/components/fastled_clockless/test.esp32-s3-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/fastled_clockless/test.esp32-s3-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From d6fba390378b6e8469254c518b3e0e5d1312026b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 18:15:01 -1000 Subject: [PATCH 166/279] [runtime_image] Add esp-dsp dependency for JPEGDEC SIMD on ESP32 (#14840) Co-authored-by: Copilot Autofix powered by AI <175728472+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/runtime_image/__init__.py | 8 ++++++++ esphome/idf_component.yml | 2 ++ .../online_image/test.esp32-s3-ard.yaml | 19 +++++++++++++++++++ .../online_image/test.esp32-s3-idf.yaml | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 tests/components/online_image/test.esp32-s3-ard.yaml create mode 100644 tests/components/online_image/test.esp32-s3-idf.yaml diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 7c22bfc9d19..3ae35cc5f17 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -11,6 +11,7 @@ from esphome.components.image import ( ) import esphome.config_validation as cv from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE +from esphome.core import CORE AUTO_LOAD = ["image"] CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"] @@ -75,6 +76,13 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + # JPEGDEC uses ESP32-S3 SIMD optimizations (guarded by board-level + # ARDUINO_ESP32S3_DEV define) that require esp-dsp headers. + # On Arduino this overwrites the stub; on IDF it adds the component. + add_idf_component(name="espressif/esp-dsp", ref="1.7.1") class PNGFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index df651ae15dd..381ecdcbf6e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -5,6 +5,8 @@ dependencies: version: 2.0.3 esphome/micro-opus: version: 0.3.5 + espressif/esp-dsp: + version: "1.7.1" espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml new file mode 100644 index 00000000000..9116fd86e09 --- /dev/null +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -0,0 +1,19 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-ard.yaml + +<<: !include common.yaml + +http_request: + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(online_rgba_image)); diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..f219f71ee25 --- /dev/null +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -0,0 +1,19 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +<<: !include common.yaml + +http_request: + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(online_rgba_image)); From ffce637ea547698c034ab6fde3d04c3ee8fc6e41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:43:38 +0000 Subject: [PATCH 167/279] Bump aioesphomeapi from 44.5.1 to 44.5.2 (#14849) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e634bcb1046..da95dd5a13d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.5.1 +aioesphomeapi==44.5.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From b8ce907976689a5f78ca4f80d483087d001fc5d8 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 16 Mar 2026 08:08:05 +0100 Subject: [PATCH 168/279] [ble_nus] fix uart debug (#14850) --- esphome/components/ble_nus/ble_nus.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index d0d37dbf1cb..2f60f814718 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -67,14 +67,14 @@ bool BLENUS::read_array(uint8_t *data, size_t len) { // First, use the peek buffer if available if (this->has_peek_) { +#ifdef USE_UART_DEBUGGER + this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); +#endif data[0] = this->peek_buffer_; this->has_peek_ = false; data++; if (--len == 0) { // Decrement len first, then check it... -#ifdef USE_UART_DEBUGGER - this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); -#endif - return true; // No more to read + return true; // No more to read } } From 0c260e483e2700401724706ffd02bc038c65fc93 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:39:26 -0400 Subject: [PATCH 169/279] [gpio][dallas_temp] Fix one_wire read64() and DS18S20 division by zero (#14866) --- esphome/components/dallas_temp/dallas_temp.cpp | 3 +++ esphome/components/gpio/one_wire/gpio_one_wire.cpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 13f2fa59bd7..f119e28e78e 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -136,6 +136,9 @@ bool DallasTemperatureSensor::check_scratch_pad_() { float DallasTemperatureSensor::get_temp_c_() { int16_t temp = (this->scratch_pad_[1] << 8) | this->scratch_pad_[0]; if ((this->address_ & 0xff) == DALLAS_MODEL_DS18S20) { + if (this->scratch_pad_[7] == 0) { + return NAN; + } return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; } switch (this->resolution_) { diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index 4191c45de15..4e2a306fc94 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -131,7 +131,7 @@ uint8_t IRAM_ATTR GPIOOneWireBus::read8() { uint64_t IRAM_ATTR GPIOOneWireBus::read64() { InterruptLock lock; uint64_t ret = 0; - for (uint8_t i = 0; i < 8; i++) { + for (uint8_t i = 0; i < 64; i++) { ret |= (uint64_t(this->read_bit_()) << i); } return ret; From bb0a5dc8a8c2df1217dd9cab0402b2e9c29e2b7d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:40:24 -0400 Subject: [PATCH 170/279] [lilygo_t5_47] Fix Y coordinate mapping and clamp touch point count (#14865) --- .../lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp index b29e4c21540..ee6c2ee4718 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp @@ -42,7 +42,7 @@ void LilygoT547Touchscreen::setup() { this->x_raw_max_ = this->display_->get_native_width(); } if (this->y_raw_max_ == this->y_raw_min_) { - this->x_raw_max_ = this->display_->get_native_height(); + this->y_raw_max_ = this->display_->get_native_height(); } } } @@ -64,6 +64,10 @@ void LilygoT547Touchscreen::update_touches() { } point = buffer[5] & 0xF; + if (point > 2) { + ESP_LOGW(TAG, "Invalid touch point count: %d", point); + point = 2; + } if (point == 1) { err = this->write_register(TOUCH_REGISTER, READ_TOUCH, 1); From f36b0fcb61f78ab2a01cfb03c57f5b1ad75b8fdc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:42:13 -0400 Subject: [PATCH 171/279] [am43] Fix battery update throttle using wrong type (#14864) --- esphome/components/am43/sensor/am43_sensor.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 91973d8e33f..195b96a19eb 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -35,7 +35,7 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent uint8_t current_sensor_; // The AM43 often gets into a state where it spams loads of battery update // notifications. Here we will limit to no more than every 10s. - uint8_t last_battery_update_; + uint32_t last_battery_update_; }; } // namespace am43 From 9133582aa0c636bd3e81bc424b46c5248427ccb3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:44:55 -0400 Subject: [PATCH 172/279] [as3935] Fix ENERGY_MASK dropping bit 4 of lightning energy MMSB (#14861) --- esphome/components/as3935/as3935.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/as3935/as3935.h b/esphome/components/as3935/as3935.h index 5dff1cb0aef..5f46dadfa8c 100644 --- a/esphome/components/as3935/as3935.h +++ b/esphome/components/as3935/as3935.h @@ -41,7 +41,7 @@ enum AS3935RegisterMasks { INT_MASK = 0xF0, THRESH_MASK = 0x0F, R_SPIKE_MASK = 0xF0, - ENERGY_MASK = 0xF0, + ENERGY_MASK = 0xE0, CAP_MASK = 0xF0, LIGHT_MASK = 0xCF, DISTURB_MASK = 0xDF, From 0816b27398b0abb63c11d6f7a10e65ef95f6dcec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:45:16 -0400 Subject: [PATCH 173/279] [core] Support both dot and dash separators in Version.parse (#14858) --- esphome/config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1eac53e9b20..32689dab27e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -314,7 +314,7 @@ class Version: @classmethod def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)-?(\w*)$", value) + match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) From d6c67d5c357669e547f94a461614976e0d3d54cb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:45:03 +1300 Subject: [PATCH 174/279] Bump version to 2026.3.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 4ec3a24c9fe..96295b3fc8e 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.3.0b2 +PROJECT_NUMBER = 2026.3.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 2466f2c49c0..561a27d2286 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0b2" +__version__ = "2026.3.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 73ca0ff1069ad2582830316890faaa9e43fad3b2 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Tue, 17 Mar 2026 14:22:31 +0100 Subject: [PATCH 175/279] [core] Small improvements (#14884) --- esphome/components/bme68x_bsec2/__init__.py | 4 ++-- script/merge_component_configs.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 4200b2f0b85..5f0afa9c9f2 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -186,8 +186,8 @@ async def to_code_base(config): cg.add_library("SPI", None) cg.add_library( "BME68x Sensor library", - "1.3.40408", - "https://github.com/boschsensortec/Bosch-BME68x-Library", + None, + "https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408", ) cg.add_library( "BSEC2 Software Library", diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5e98f1fef5a..41bbafcd021 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -384,7 +384,7 @@ def merge_component_configs( # Write merged config output_file.parent.mkdir(parents=True, exist_ok=True) yaml_content = yaml_util.dump(merged_config_data) - output_file.write_text(yaml_content) + output_file.write_text(yaml_content, encoding="utf-8") print(f"Successfully merged {len(component_names)} components into {output_file}") From b083491e7493f4f2f1c6acadee673c0c05e29bd0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:46:32 -0400 Subject: [PATCH 176/279] [microphone] Switch IDF test to new I2S driver (#14886) --- tests/components/microphone/common.yaml | 10 +++++++++- .../components/microphone/test.esp32-idf.yaml | 20 +++---------------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/components/microphone/common.yaml b/tests/components/microphone/common.yaml index 00d33bcc3db..39ab06da616 100644 --- a/tests/components/microphone/common.yaml +++ b/tests/components/microphone/common.yaml @@ -6,7 +6,7 @@ i2s_audio: microphone: - platform: i2s_audio id: mic_id_external - i2s_din_pin: ${i2s_din_pin} + i2s_din_pin: ${i2s_din_pin1} adc_type: external pdm: false mclk_multiple: 384 @@ -15,7 +15,15 @@ microphone: - if: condition: - microphone.is_muted: + id: mic_id_external then: - microphone.unmute: + id: mic_id_external else: - microphone.mute: + id: mic_id_external + - platform: i2s_audio + id: mic_id_pdm + i2s_din_pin: ${i2s_din_pin2} + adc_type: external + pdm: true diff --git a/tests/components/microphone/test.esp32-idf.yaml b/tests/components/microphone/test.esp32-idf.yaml index 830f0156d77..2f39263a435 100644 --- a/tests/components/microphone/test.esp32-idf.yaml +++ b/tests/components/microphone/test.esp32-idf.yaml @@ -2,21 +2,7 @@ substitutions: i2s_bclk_pin: GPIO15 i2s_lrclk_pin: GPIO4 i2s_mclk_pin: GPIO5 - i2s_din_pin: GPIO33 + i2s_din_pin1: GPIO33 + i2s_din_pin2: GPIO34 -i2s_audio: - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - use_legacy: true - -microphone: - - platform: i2s_audio - id: mic_id_external - i2s_din_pin: ${i2s_din_pin} - adc_type: external - pdm: false - - platform: i2s_audio - id: mic_id_adc - adc_pin: 32 - adc_type: internal +<<: !include common.yaml From 3826e9550616bf154fcdcb2541289b9bf2f3a1db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 08:14:36 -1000 Subject: [PATCH 177/279] [api] Fix ProtoMessage protected destructor compile error on host platform (#14882) --- esphome/components/api/proto.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 6752dfb9cd0..d6e993d3a5a 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -442,8 +442,12 @@ class ProtoMessage { virtual const char *message_name() const { return "unknown"; } #endif +#ifndef USE_HOST protected: +#endif // Non-virtual destructor is protected to prevent polymorphic deletion. + // On host platform, made public to allow value-initialization of std::array + // members (e.g. DeviceInfoResponse::devices) without clang errors. ~ProtoMessage() = default; }; From 82ccc37ba11a81894a38c729ab6f176db86b87ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 08:14:52 -1000 Subject: [PATCH 178/279] [ethernet] Mark EthernetComponent as final (#14842) --- esphome/components/ethernet/ethernet_component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 901d9bc0bb4..88a86bc0432 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -85,7 +85,7 @@ enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; #endif -class EthernetComponent : public Component { +class EthernetComponent final : public Component { public: EthernetComponent(); void setup() override; From b3210de374aec9f63126af7617cf31d235875abf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 08:53:36 -1000 Subject: [PATCH 179/279] [core] Extract shared C++ build helpers from cpp_unit_test.py (#14883) --- script/cpp_unit_test.py | 257 ++++---------------------- script/test_helpers.py | 400 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 436 insertions(+), 221 deletions(-) create mode 100644 script/test_helpers.py diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c6cfd8270fa..81c56b82da9 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -1,238 +1,53 @@ #!/usr/bin/env python3 import argparse -import hashlib -import os from pathlib import Path -import subprocess import sys -from helpers import get_all_components, get_all_dependencies, root_path - -from esphome.__main__ import command_compile, parse_args -from esphome.config import validate_config -from esphome.const import CONF_PLATFORM -from esphome.core import CORE -from esphome.loader import get_component -from esphome.platformio_api import get_idedata - -# This must coincide with the version in /platformio.ini -PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" +from helpers import get_all_components, root_path +from test_helpers import ( + BASE_CODEGEN_COMPONENTS, + PLATFORMIO_GOOGLE_TEST_LIB, + USE_TIME_TIMEZONE_FLAG, + build_and_run, +) # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" -# Components whose to_code should run during C++ test builds. -# Most components don't need code generation for tests; only these -# essential ones (platform setup, logging, core config) are needed. -# Note: "core" is the esphome core config module (esphome/core/config.py), -# which registers under package name "core" not "esphome". -CPP_TESTING_CODEGEN_COMPONENTS = {"core", "host", "logger"} - - -def hash_components(components: list[str]) -> str: - key = ",".join(components) - return hashlib.sha256(key.encode()).hexdigest()[:16] - - -def filter_components_without_tests(components: list[str]) -> list[str]: - """Filter out components that do not have a corresponding test file. - - This is done by checking if the component's directory contains at - least a .cpp or .h file. - """ - filtered_components: list[str] = [] - for component in components: - test_dir = COMPONENTS_TESTS_DIR / component - if test_dir.is_dir() and ( - any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h")) - ): - filtered_components.append(component) - else: - print( - f"WARNING: No tests found for component '{component}', skipping.", - file=sys.stderr, - ) - return filtered_components - - -def create_test_config(config_name: str, includes: list[str]) -> dict: - """Create ESPHome test configuration for C++ unit tests. - - Args: - config_name: Unique name for this test configuration - includes: List of include folders for the test build - - Returns: - Configuration dict for ESPHome - """ - return { - "esphome": { - "name": config_name, - "friendly_name": "CPP Unit Tests", - "libraries": PLATFORMIO_GOOGLE_TEST_LIB, - "platformio_options": { - "build_type": "debug", - "build_unflags": [ - "-Os", # remove size-opt flag - ], - "build_flags": [ - "-Og", # optimize for debug - "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing - "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", - "-fno-omit-frame-pointer", - ], - "debug_build_flags": [ # only for debug builds - "-g3", # max debug info - "-ggdb3", - ], - }, - "includes": includes, - }, - "host": {}, - "logger": {"level": "DEBUG"}, - } - - -def get_platform_components(components: list[str]) -> list[str]: - """Discover platform sub-components referenced by test directory structure. - - For each component being tested, any sub-directory named after a platform - domain (e.g. ``sensor``, ``binary_sensor``) is treated as a request to - include that ``.`` platform in the build. The sub- - directory must name a valid platform domain; anything else raises an error - so that typos are caught early. - - Returns: - List of ``"domain.component"`` strings, one per discovered sub-directory. - """ - platform_components: list[str] = [] - for component in components: - test_dir = COMPONENTS_TESTS_DIR / component - if not test_dir.is_dir(): - continue - # Each sub-directory name is expected to be a platform domain - # (e.g. tests/components/bthome/sensor/ → sensor.bthome). - for domain_dir in test_dir.iterdir(): - if not domain_dir.is_dir(): - continue - domain = domain_dir.name - domain_module = get_component(domain) - if domain_module is None or not domain_module.is_platform_component: - raise ValueError( - f"Component tests for '{component}' reference non-existing or invalid domain '{domain}'" - f" in its directory structure. See ({COMPONENTS_TESTS_DIR / component / domain})." - ) - platform_components.append(f"{domain}.{component}") - return platform_components - - -# Exit codes for run_tests -EXIT_OK = 0 -EXIT_SKIPPED = 1 -EXIT_COMPILE_ERROR = 2 -EXIT_CONFIG_ERROR = 3 -EXIT_NO_EXECUTABLE = 4 +PLATFORMIO_OPTIONS = { + "build_type": "debug", + "build_unflags": [ + "-Os", # remove size-opt flag + ], + "build_flags": [ + "-Og", # optimize for debug + USE_TIME_TIMEZONE_FLAG, + "-DESPHOME_DEBUG", # enable debug assertions + # Enable the address and undefined behavior sanitizers + "-fsanitize=address", + "-fsanitize=undefined", + "-fno-omit-frame-pointer", + ], + "debug_build_flags": [ # only for debug builds + "-g3", # max debug info + "-ggdb3", + ], +} def run_tests(selected_components: list[str]) -> int: - # Skip tests on Windows - if os.name == "nt": - print("Skipping esphome tests on Windows", file=sys.stderr) - return EXIT_SKIPPED - - # Remove components that do not have tests - components = filter_components_without_tests(selected_components) - - if len(components) == 0: - print( - "No components specified or no tests found for the specified components.", - file=sys.stderr, - ) - return EXIT_OK - - components = sorted(components) - - # Build a list of include folders relative to COMPONENTS_TESTS_DIR. These folders will - # be added along with their subfolders. - # "main.cpp" is a special entry that points to /tests/components/main.cpp, - # which provides a custom test runner entry-point replacing the default one. - # Each remaining entry is a component folder whose *.cpp files are compiled. - includes: list[str] = ["main.cpp"] + components - - # Obtain a list of platform components to be tested: - try: - platform_components = get_platform_components(components) - except ValueError as e: - print(f"Error obtaining platform components: {e}") - return EXIT_CONFIG_ERROR - - components = sorted(components + platform_components) - - # Create a unique name for this config based on the actual components being tested - # to maximize cache during testing - config_name: str = "cpptests-" + hash_components(components) - - # Obtain possible dependencies for the requested components. - # Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag, - # which causes core/time.h to include components/time/posix_tz.h. - components_with_dependencies: list[str] = sorted( - get_all_dependencies(set(components) | {"time"}, cpp_testing=True) + return build_and_run( + selected_components=selected_components, + tests_dir=COMPONENTS_TESTS_DIR, + codegen_components=BASE_CODEGEN_COMPONENTS, + config_prefix="cpptests", + friendly_name="CPP Unit Tests", + libraries=PLATFORMIO_GOOGLE_TEST_LIB, + platformio_options=PLATFORMIO_OPTIONS, + main_entry="main.cpp", + label="unit tests", ) - config = create_test_config(config_name, includes) - - CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml" - CORE.dashboard = None - CORE.cpp_testing = True - CORE.cpp_testing_codegen = CPP_TESTING_CODEGEN_COMPONENTS - - # Validate config will expand the above with defaults: - config = validate_config(config, {}) - - # Add all components and dependencies to the base configuration after validation, so their files - # are added to the build. - for component_name in components_with_dependencies: - if "." in component_name: - # Format is always "domain.component" (exactly one dot), - # as produced by get_platform_components(). - domain, component = component_name.split(".", maxsplit=1) - domain_list = config.setdefault(domain, []) - CORE.testing_ensure_platform_registered(domain) - domain_list.append({CONF_PLATFORM: component}) - else: - config.setdefault(component_name, []) - - dependencies = set(components_with_dependencies) - set(components) - deps_str = ", ".join(dependencies) if dependencies else "None" - print(f"Testing components: {', '.join(components)}. Dependencies: {deps_str}") - CORE.config = config - args = parse_args(["program", "compile", str(CORE.config_path)]) - try: - exit_code: int = command_compile(args, config) - - if exit_code != 0: - print(f"Error compiling unit tests for {', '.join(components)}") - return exit_code - except Exception as e: - print( - f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}" - ) - return EXIT_COMPILE_ERROR - - # After a successful compilation, locate the executable and run it: - idedata = get_idedata(config) - if idedata is None: - print("Cannot find executable") - return EXIT_NO_EXECUTABLE - - program_path: str = idedata.raw["prog_path"] - run_cmd: list[str] = [program_path] - run_proc = subprocess.run(run_cmd, check=False) - return run_proc.returncode - def main() -> None: parser = argparse.ArgumentParser( diff --git a/script/test_helpers.py b/script/test_helpers.py new file mode 100644 index 00000000000..e872bbc5160 --- /dev/null +++ b/script/test_helpers.py @@ -0,0 +1,400 @@ +"""Shared helpers for C++ unit test and benchmark build scripts.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import subprocess +import sys + +from helpers import get_all_dependencies +import yaml + +from esphome.__main__ import command_compile, parse_args +from esphome.config import validate_config +from esphome.const import CONF_PLATFORM +from esphome.core import CORE +from esphome.loader import get_component +from esphome.platformio_api import get_idedata + +# This must coincide with the version in /platformio.ini +PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" + +# Google Benchmark library for PlatformIO +# Format: name=repository_url (see esphome/core/config.py library parsing) +PLATFORMIO_GOOGLE_BENCHMARK_LIB = ( + "benchmark=https://github.com/google/benchmark.git#v1.9.1" +) + +# Key names for the base config sections +ESPHOME_KEY = "esphome" +HOST_KEY = "host" +LOGGER_KEY = "logger" + +# Base config keys that are always present and must not be fully overridden +# by component benchmark.yaml files. esphome: allows sub-key merging. +BASE_CONFIG_KEYS = frozenset({ESPHOME_KEY, HOST_KEY, LOGGER_KEY}) + +# Shared build flag — enables timezone code paths for testing/benchmarking. +USE_TIME_TIMEZONE_FLAG = "-DUSE_TIME_TIMEZONE" + +# Components whose to_code should always run during C++ test/benchmark builds. +# These are the minimal infrastructure components needed for host compilation. +# Note: "core" is the esphome core config module (esphome/core/config.py), +# which registers under package name "core" not "esphome". +BASE_CODEGEN_COMPONENTS = {"core", "host", "logger"} + +# Exit codes +EXIT_OK = 0 +EXIT_SKIPPED = 1 +EXIT_COMPILE_ERROR = 2 +EXIT_CONFIG_ERROR = 3 +EXIT_NO_EXECUTABLE = 4 + +# Name of the per-component YAML config file in benchmark directories +BENCHMARK_YAML_FILENAME = "benchmark.yaml" + + +def hash_components(components: list[str]) -> str: + """Create a short hash of component names for unique config naming.""" + key = ",".join(components) + return hashlib.sha256(key.encode()).hexdigest()[:16] + + +def filter_components_with_files(components: list[str], tests_dir: Path) -> list[str]: + """Filter out components that do not have .cpp or .h files in the tests dir. + + Args: + components: List of component names to check + tests_dir: Base directory containing component test/benchmark folders + + Returns: + Filtered list of components that have test files + """ + filtered_components: list[str] = [] + for component in components: + test_dir = tests_dir / component + if test_dir.is_dir() and ( + any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h")) + ): + filtered_components.append(component) + else: + print( + f"WARNING: No files found for component '{component}' in {test_dir}, skipping.", + file=sys.stderr, + ) + return filtered_components + + +def get_platform_components(components: list[str], tests_dir: Path) -> list[str]: + """Discover platform sub-components referenced by test directory structure. + + For each component, any sub-directory named after a platform domain + (e.g. ``sensor``, ``binary_sensor``) is treated as a request to include + that ``.`` platform in the build. + + Args: + components: List of component names to scan + tests_dir: Base directory containing component test/benchmark folders + + Returns: + List of ``"domain.component"`` strings + """ + platform_components: list[str] = [] + for component in components: + test_dir = tests_dir / component + if not test_dir.is_dir(): + continue + for domain_dir in test_dir.iterdir(): + if not domain_dir.is_dir(): + continue + domain = domain_dir.name + domain_module = get_component(domain) + if domain_module is None or not domain_module.is_platform_component: + raise ValueError( + f"Component '{component}' references non-existing or invalid domain '{domain}'" + f" in its directory structure. See ({tests_dir / component / domain})." + ) + platform_components.append(f"{domain}.{component}") + return platform_components + + +def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: + """Load and merge benchmark.yaml files from component directories. + + Each component directory may contain a ``benchmark.yaml`` file that + declares additional ESPHome components needed for the build (e.g. + ``api:``, ``sensor:``). These get merged into the base config before + validation so that dependencies are properly resolved with defaults. + + The ``esphome:`` key is special: its sub-keys are merged into the + existing esphome config (e.g. to add ``areas:`` or ``devices:``). + Other base config keys (``host:``, ``logger:``) are not overridable. + + Args: + components: List of component directory names + tests_dir: Base directory containing component folders + + Returns: + Merged dict of component configs to add to the base config + """ + # Note: components are processed in sorted order. For conflicting keys + # (e.g. two benchmark.yaml files both declaring sensor:), the first + # component alphabetically wins via setdefault(). This is fine for now + # with a single benchmark component (api) but would need a real merge + # strategy if multiple components declare overlapping configs. + merged: dict = {} + for component in components: + yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME + if not yaml_path.is_file(): + continue + with open(yaml_path) as f: + component_config = yaml.safe_load(f) + if component_config and isinstance(component_config, dict): + for key, value in component_config.items(): + if key in BASE_CONFIG_KEYS - {ESPHOME_KEY}: + # host: and logger: are not overridable + continue + if key == ESPHOME_KEY and isinstance(value, dict): + # Merge esphome sub-keys rather than replacing + esphome_extra = merged.setdefault(ESPHOME_KEY, {}) + for sub_key, sub_value in value.items(): + esphome_extra.setdefault(sub_key, sub_value) + continue + merged.setdefault(key, value) + return merged + + +def create_host_config( + config_name: str, + friendly_name: str, + libraries: str | list[str], + includes: list[str], + platformio_options: dict, +) -> dict: + """Create an ESPHome host configuration for C++ builds. + + Args: + config_name: Unique name for this configuration + friendly_name: Human-readable name + libraries: PlatformIO library specification(s) + includes: List of include folders for the build + platformio_options: Dict of platformio_options to set + + Returns: + Configuration dict for ESPHome + """ + return { + ESPHOME_KEY: { + "name": config_name, + "friendly_name": friendly_name, + "libraries": libraries, + "platformio_options": platformio_options, + "includes": includes, + }, + HOST_KEY: {}, + LOGGER_KEY: {"level": "DEBUG"}, + } + + +def compile_and_get_binary( + config: dict, + components: list[str], + codegen_components: set[str], + tests_dir: Path, + label: str = "build", +) -> tuple[int, str | None]: + """Compile an ESPHome configuration and return the binary path. + + Args: + config: ESPHome configuration dict (already created via create_host_config) + components: List of components to include in the build + codegen_components: Set of component names whose to_code should run + tests_dir: Base directory for test files (used as config_path base) + label: Label for log messages (e.g. "unit tests", "benchmarks") + + Returns: + Tuple of (exit_code, program_path_or_none) + """ + # Load any benchmark.yaml files from component directories and merge + # them into the config BEFORE dependency resolution and validation. + # This allows each benchmark/test dir to declare which ESPHome components + # it needs (e.g. api:) so they get proper config defaults. + extra_config = load_component_yaml_configs(components, tests_dir) + for key, value in extra_config.items(): + if key == ESPHOME_KEY and isinstance(value, dict): + # Merge esphome sub-keys into existing esphome config. + # For list values (e.g. libraries), extend rather than replace. + for sub_key, sub_value in value.items(): + existing = config[ESPHOME_KEY].get(sub_key) + if existing is not None and isinstance(sub_value, list): + # Ensure existing is a list, then extend + if not isinstance(existing, list): + config[ESPHOME_KEY][sub_key] = [existing] + config[ESPHOME_KEY][sub_key].extend(sub_value) + else: + config[ESPHOME_KEY].setdefault(sub_key, sub_value) + else: + config.setdefault(key, value) + + # Obtain possible dependencies BEFORE validate_config, because + # get_all_dependencies calls CORE.reset() which clears build_path. + # Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag, + # which causes core/time.h to include components/time/posix_tz.h. + components_with_dependencies: list[str] = sorted( + get_all_dependencies(set(components) | {"time"}, cpp_testing=True) + ) + + CORE.config_path = tests_dir / "dummy.yaml" + CORE.dashboard = None + CORE.cpp_testing = True + CORE.cpp_testing_codegen = codegen_components + + # Validate config will expand the above with defaults: + config = validate_config(config, {}) + + # Add remaining components and dependencies to the configuration after + # validation, so their source files are included in the build. + for component_name in components_with_dependencies: + if "." in component_name: + domain, component = component_name.split(".", maxsplit=1) + domain_list = config.setdefault(domain, []) + CORE.testing_ensure_platform_registered(domain) + domain_list.append({CONF_PLATFORM: component}) + # Skip "core" — it's a pseudo-component handled by the build + # system, not a real loadable component (get_component returns None) + elif get_component(component_name) is not None: + config.setdefault(component_name, []) + + # Register platforms from the extra config (benchmark.yaml) so + # USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing + # real entity instances. + for key in extra_config: + if key == ESPHOME_KEY: + continue + comp = get_component(key) + if comp is not None and comp.is_platform_component: + CORE.testing_ensure_platform_registered(key) + + dependencies = set(components_with_dependencies) - set(components) + deps_str = ", ".join(dependencies) if dependencies else "None" + print(f"Building {label}: {', '.join(components)}. Dependencies: {deps_str}") + CORE.config = config + args = parse_args(["program", "compile", str(CORE.config_path)]) + try: + exit_code: int = command_compile(args, config) + + if exit_code != 0: + print(f"Error compiling {label} for {', '.join(components)}") + return exit_code, None + except Exception as e: + print(f"Error compiling {label} for {', '.join(components)}: {e}") + return EXIT_COMPILE_ERROR, None + + # After a successful compilation, locate the executable: + idedata = get_idedata(config) + if idedata is None: + print("Cannot find executable") + return EXIT_NO_EXECUTABLE, None + + program_path: str = idedata.raw["prog_path"] + return EXIT_OK, program_path + + +def build_and_run( + selected_components: list[str], + tests_dir: Path, + codegen_components: set[str], + config_prefix: str, + friendly_name: str, + libraries: str | list[str], + platformio_options: dict, + main_entry: str, + label: str = "build", + build_only: bool = False, + extra_run_args: list[str] | None = None, + extra_include_dirs: list[Path] | None = None, +) -> int: + """Build and optionally run a C++ test/benchmark binary. + + This is the main orchestration function shared between unit tests + and benchmarks. + + Args: + selected_components: Components to include (directory names in tests_dir) + tests_dir: Directory containing test/benchmark files + codegen_components: Components whose to_code should run + config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench") + friendly_name: Human-readable name for the config + libraries: PlatformIO library specification(s) + platformio_options: PlatformIO options dict + main_entry: Name of the main entry file (e.g. "main.cpp") + label: Label for log messages + build_only: If True, print binary path and return without running + extra_run_args: Extra arguments to pass to the binary + extra_include_dirs: Additional directories whose .cpp files + should be compiled (resolved relative to tests_dir if possible) + + Returns: + Exit code + """ + # Skip on Windows + if os.name == "nt": + print(f"Skipping {label} on Windows", file=sys.stderr) + return EXIT_SKIPPED + + # Remove components that do not have files + components = filter_components_with_files(selected_components, tests_dir) + + if len(components) == 0: + print( + f"No components specified or no files found for {label}.", + file=sys.stderr, + ) + return EXIT_OK + + components = sorted(components) + + # Build include list: main entry point + component folders + extra dirs + includes: list[str] = [main_entry] + components + if extra_include_dirs: + for d in extra_include_dirs: + if d.is_dir() and (any(d.glob("*.cpp")) or any(d.glob("*.h"))): + # ESPHome includes are relative to the config directory (tests_dir) + rel = os.path.relpath(d, tests_dir) + includes.append(rel) + + # Discover platform sub-components + try: + platform_components = get_platform_components(components, tests_dir) + except ValueError as e: + print(f"Error obtaining platform components: {e}") + return EXIT_CONFIG_ERROR + + components = sorted(components + platform_components) + + # Create unique config name + config_name: str = f"{config_prefix}-" + hash_components(components) + + config = create_host_config( + config_name, friendly_name, libraries, includes, platformio_options + ) + + exit_code, program_path = compile_and_get_binary( + config, components, codegen_components, tests_dir, label + ) + + if exit_code != EXIT_OK or program_path is None: + return exit_code + + if build_only: + print(f"BUILD_BINARY={program_path}") + return EXIT_OK + + # Run the binary + run_cmd: list[str] = [program_path] + if extra_run_args: + run_cmd.extend(extra_run_args) + run_proc = subprocess.run(run_cmd, check=False) + return run_proc.returncode From 53fa346ddc6ce6e99ff712ddc8964841368c6851 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:18:49 -0400 Subject: [PATCH 180/279] [speaker] Fix media playlist using announcement delay (#14889) --- .../components/speaker/media_player/speaker_media_player.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 9f168f854d8..930373c6fcd 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -417,7 +417,7 @@ void SpeakerMediaPlayer::loop() { this->media_playlist_.pop_front(); } // Only delay starting playback if moving on the next playlist item or repeating the current item - timeout_ms = this->announcement_playlist_delay_ms_; + timeout_ms = this->media_playlist_delay_ms_; } if (!this->media_playlist_.empty()) { PlaylistItem playlist_item = this->media_playlist_.front(); From 9a729608d57f993deac55144e39414185421aa3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 09:58:05 -1000 Subject: [PATCH 181/279] [core] Add back deprecated set_internal() for external projects (#14887) --- esphome/core/entity_base.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 012a62f1c08..4c6e5f6596b 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -100,6 +100,14 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } + // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients + // are NOT notified of the change, the flag may have already been read during setup, and there + // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. + ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " + "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", + "2026.3.0") + void set_internal(bool internal) { this->flags_.internal = internal; } + // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should // not be added to the default view by default, and a user action is necessary to manually add it. From 851e8b6c0def9938b9603887166a3d1f2693c03b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:28:13 -0400 Subject: [PATCH 182/279] [gree] Fix IR checksum for YAA/YAC/YAC1FB9/GENERIC models (#14888) --- esphome/components/gree/gree.cpp | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 8a9f264932c..732ebd9632e 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -87,19 +87,12 @@ void GreeClimate::transmit_state() { // Calculate the checksum if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF) { remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0); - } else if (this->model_ == GREE_YAG) { + } else { remote_state[7] = ((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) + ((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) & 0x0F) << 4); - } else { - remote_state[7] = - ((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) + - ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + ((remote_state[7] & 0xF0) >> 4) + 0x0A) & - 0x0F) - << 4) | - (remote_state[7] & 0x0F); } auto transmit = this->transmitter_->transmit(); From 5f06679d78316582e09b8d0f6f56ef0dd8816b1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:16:44 -1000 Subject: [PATCH 183/279] [espnow] Fix EventPool/LockFreeQueue sizing off-by-one (#14893) --- esphome/components/espnow/espnow_component.cpp | 6 ++++-- esphome/components/espnow/espnow_component.h | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 991803d8703..78916891f4d 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -87,7 +87,8 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) // Push the packet to the queue global_esp_now->receive_packet_queue_.push(packet); - // Push always because we're the only producer and the pool ensures we never exceed queue size + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. // Wake main loop immediately to process ESP-NOW send event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) @@ -109,7 +110,8 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // Push the packet to the queue global_esp_now->receive_packet_queue_.push(packet); - // Push always because we're the only producer and the pool ensures we never exceed queue size + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. // Wake main loop immediately to process ESP-NOW receive event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 9941e972270..ee4adc1b4d4 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -163,10 +163,14 @@ class ESPNowComponent : public Component { uint8_t own_address_[ESP_NOW_ETH_ALEN]{0}; LockFreeQueue receive_packet_queue_{}; - EventPool receive_packet_pool_{}; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing a pool slot leak. + EventPool receive_packet_pool_{}; LockFreeQueue send_packet_queue_{}; - EventPool send_packet_pool_{}; + // Pool sized to queue capacity (SIZE-1) — see receive_packet_pool_ comment. + EventPool send_packet_pool_{}; ESPNowSendPacket *current_send_packet_{nullptr}; // Currently sending packet, nullptr if none uint8_t wifi_channel_{0}; From 97382ed8148fbe42c18bfd1ed7c10cd76b3b922e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:17:43 -1000 Subject: [PATCH 184/279] [usb_cdc_acm] Fix EventPool/LockFreeQueue sizing off-by-one (#14894) --- .../components/usb_cdc_acm/usb_cdc_acm.cpp | 26 +++++++------------ esphome/components/usb_cdc_acm/usb_cdc_acm.h | 6 ++++- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index a4c2e6c4a48..253626f0a3e 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -26,16 +26,13 @@ void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { event->data.line_state.dtr = dtr; event->data.line_state.rts = rts; - if (!this->event_queue_.push(event)) { - ESP_LOGW(TAG, "Event queue full, line state event dropped (itf=%d)", this->itf_); - // Return event to pool since we couldn't queue it - this->event_pool_.release(event); - } else { - // Wake main loop immediately to process event + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + this->event_queue_.push(event); + #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - App.wake_loop_threadsafe(); + App.wake_loop_threadsafe(); #endif - } } void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, @@ -53,16 +50,13 @@ void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_ event->data.line_coding.parity = parity; event->data.line_coding.data_bits = data_bits; - if (!this->event_queue_.push(event)) { - ESP_LOGW(TAG, "Event queue full, line coding event dropped (itf=%d)", this->itf_); - // Return event to pool since we couldn't queue it - this->event_pool_.release(event); - } else { - // Wake main loop immediately to process event + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + this->event_queue_.push(event); + #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - App.wake_loop_threadsafe(); + App.wake_loop_threadsafe(); #endif - } } void USBCDCACMInstance::process_events_() { diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 020542e7490..a56abc9ee8e 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -102,7 +102,11 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented event_pool_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing both a pool slot leak and an SPSC + // violation on the pool's internal free list. + EventPool event_pool_; LockFreeQueue event_queue_; }; From c19c75220bbf2f20e37dec81ac18dc0735e5e058 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:17:59 -1000 Subject: [PATCH 185/279] [usb_host] Fix EventPool/LockFreeQueue sizing off-by-one (#14896) --- esphome/components/usb_host/usb_host.h | 5 ++++- esphome/components/usb_host/usb_host_client.cpp | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 2eec0c9699b..dcb76a3a3ba 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -144,7 +144,10 @@ class USBClient : public Component { // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks LockFreeQueue event_queue; - EventPool event_pool; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing a pool slot leak. + EventPool event_pool; protected: // Process USB events from the queue. Returns true if any work was done. diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 2a460d1a077..18d938344c7 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -193,7 +193,8 @@ static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void * return; } - // Push to lock-free queue (always succeeds since pool size == queue size) + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. client->event_queue.push(event); // Re-enable component loop to process the queued event From a94bb74d043da0b1128c7a899a1765a3e13f6af0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:18:31 -1000 Subject: [PATCH 186/279] [usb_uart] Fix EventPool/LockFreeQueue sizing off-by-one (#14895) --- esphome/components/usb_uart/usb_uart.cpp | 11 +++++------ esphome/components/usb_uart/usb_uart.h | 8 ++++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 997f8361464..a5d312f1912 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -160,11 +160,9 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { size_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); chunk->length = static_cast(chunk_len); - if (!this->output_queue_.push(chunk)) { - this->output_pool_.release(chunk); - ESP_LOGE(TAG, "Output queue full - lost %zu bytes", len); - break; - } + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + this->output_queue_.push(chunk); data += chunk_len; len -= chunk_len; } @@ -320,7 +318,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { chunk->channel = channel; // Push to lock-free queue for main loop processing - // Push always succeeds because pool size == queue size + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. this->usb_data_queue_.push(chunk); // Re-enable component loop to process the queued data diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 16469df7f60..7a06b04f114 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -158,7 +158,10 @@ class USBUartChannel : public uart::UARTComponent, public Parented output_queue_; - EventPool output_pool_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing a pool slot leak. + EventPool output_pool_; std::function rx_callback_{}; CdcEps cdc_dev_{}; StringRef debug_prefix_{}; @@ -190,7 +193,8 @@ class USBUartComponent : public usb_host::USBClient { // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; - EventPool chunk_pool_; + // Pool sized to queue capacity (SIZE-1) — see USBUartChannel::output_pool_ comment. + EventPool chunk_pool_; protected: std::vector channels_{}; From 1adf05e2d59b60e7b22d0a0efedd5f9181f1fa12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:24:02 -1000 Subject: [PATCH 187/279] [esp32_ble] Fix EventPool/LockFreeQueue sizing off-by-one (#14892) --- esphome/components/esp32_ble/ble.cpp | 3 ++- esphome/components/esp32_ble/ble.h | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ff9d9bb15aa..fee1c546be4 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -575,8 +575,9 @@ template void enqueue_ble_event(Args... args) { load_ble_event(event, args...); // Push the event to the queue + // Push always succeeds: pool is sized to queue capacity (N-1), so if + // allocate() returned non-null, the queue is guaranteed to have room. global_ble->ble_events_.push(event); - // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size } // Explicit template instantiations for the friend function diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 04bec3f7858..752ddc9d1ff 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -221,7 +221,13 @@ class ESP32BLE : public Component { // Large objects (size depends on template parameters, but typically aligned to 4 bytes) esphome::LockFreeQueue ble_events_; - esphome::EventPool ble_event_pool_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements (one slot distinguishes full from empty). + // This guarantees allocate() returns nullptr before push() can fail, which: + // 1. Prevents leaking a pool slot (the Nth allocate succeeds but push fails) + // 2. Avoids needing release() on the producer path after a failed push(), + // preserving the SPSC contract on the pool's internal free list + esphome::EventPool ble_event_pool_; // 4-byte aligned members #ifdef USE_ESP32_BLE_ADVERTISING From 1670f04a8715380be9452e37d0b0c133c092c5f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:29:38 -1000 Subject: [PATCH 188/279] [core] Add CodSpeed C++ benchmarks for protobuf, main loop, and helpers (#14878) --- .github/workflows/ci.yml | 34 ++ esphome/core/component.h | 2 + script/clang-tidy | 3 + script/cpp_benchmark.py | 130 ++++++++ script/determine-jobs.py | 61 ++++ script/setup_codspeed_lib.py | 231 ++++++++++++++ tests/benchmarks/components/.gitignore | 2 + .../components/api/bench_proto_decode.cpp | 93 ++++++ .../components/api/bench_proto_encode.cpp | 298 ++++++++++++++++++ .../components/api/bench_proto_varint.cpp | 133 ++++++++ .../benchmarks/components/api/benchmark.yaml | 114 +++++++ tests/benchmarks/components/main.cpp | 42 +++ .../core/bench_application_loop.cpp | 22 ++ tests/benchmarks/core/bench_helpers.cpp | 41 +++ tests/benchmarks/core/bench_logger.cpp | 54 ++++ tests/benchmarks/core/bench_scheduler.cpp | 133 ++++++++ tests/script/test_determine_jobs.py | 148 +++++++++ 17 files changed, 1541 insertions(+) create mode 100755 script/cpp_benchmark.py create mode 100755 script/setup_codspeed_lib.py create mode 100644 tests/benchmarks/components/.gitignore create mode 100644 tests/benchmarks/components/api/bench_proto_decode.cpp create mode 100644 tests/benchmarks/components/api/bench_proto_encode.cpp create mode 100644 tests/benchmarks/components/api/bench_proto_varint.cpp create mode 100644 tests/benchmarks/components/api/benchmark.yaml create mode 100644 tests/benchmarks/components/main.cpp create mode 100644 tests/benchmarks/core/bench_application_loop.cpp create mode 100644 tests/benchmarks/core/bench_helpers.cpp create mode 100644 tests/benchmarks/core/bench_logger.cpp create mode 100644 tests/benchmarks/core/bench_scheduler.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7710589c50..1926ad5bf4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,7 @@ jobs: cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }} cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }} component-test-batches: ${{ steps.determine.outputs.component-test-batches }} + benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -227,6 +228,7 @@ jobs: echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT + echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 @@ -308,6 +310,38 @@ jobs: script/cpp_unit_test.py $ARGS fi + benchmarks: + name: Run CodSpeed benchmarks + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Build benchmarks + id: build + run: | + . venv/bin/activate + export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + # --build-only prints BUILD_BINARY= to stdout + BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + echo "binary=$BINARY" >> $GITHUB_OUTPUT + + - name: Run CodSpeed benchmarks + uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4 + with: + run: ${{ steps.build.outputs.binary }} + mode: simulation + clang-tidy-single: name: ${{ matrix.name }} runs-on: ubuntu-24.04 diff --git a/esphome/core/component.h b/esphome/core/component.h index 5fdf23e128a..557ba09bbc9 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -598,9 +598,11 @@ class WarnIfComponentBlockingGuard { #ifdef USE_RUNTIME_STATS this->record_runtime_stats_(); #endif +#ifndef USE_BENCHMARK if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { warn_blocking(this->component_, blocking_time); } +#endif return curr_time; } diff --git a/script/clang-tidy b/script/clang-tidy index 9c2899026d2..f2834b44ac8 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -231,6 +231,9 @@ def main(): cwd = os.getcwd() files = [os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp"])] + # Exclude benchmark files — they require google benchmark headers not + # available in the ESP32 toolchain and use different naming conventions. + files = [f for f in files if not f.startswith("tests/benchmarks/")] # Print initial file count if it's large if len(files) > 50: diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py new file mode 100755 index 00000000000..bd92266ea62 --- /dev/null +++ b/script/cpp_benchmark.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Build and run C++ benchmarks for ESPHome components using Google Benchmark.""" + +import argparse +import json +import os +from pathlib import Path +import sys + +from helpers import root_path +from test_helpers import ( + BASE_CODEGEN_COMPONENTS, + PLATFORMIO_GOOGLE_BENCHMARK_LIB, + USE_TIME_TIMEZONE_FLAG, + build_and_run, +) + +# Path to /tests/benchmarks/components +BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" + +# Path to /tests/benchmarks/core (always included, not a component) +CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" + +# Additional codegen components beyond the base set. +# json is needed because its to_code adds the ArduinoJson library +# (auto-loaded by api, but cpp_testing suppresses to_code unless listed). +BENCHMARK_CODEGEN_COMPONENTS = BASE_CODEGEN_COMPONENTS | {"json"} + +PLATFORMIO_OPTIONS = { + "build_unflags": [ + "-Os", # remove default size-opt + ], + "build_flags": [ + "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) + "-g", # debug symbols for profiling + USE_TIME_TIMEZONE_FLAG, + "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + ], + # Use deep+ LDF mode to ensure PlatformIO detects the benchmark + # library dependency from nested includes. + "lib_ldf_mode": "deep+", +} + + +def run_benchmarks(selected_components: list[str], build_only: bool = False) -> int: + # Allow CI to override the benchmark library (e.g. with CodSpeed's fork). + # BENCHMARK_LIB_CONFIG is a JSON string from setup_codspeed_lib.py + # containing {"lib_path": "/path/to/google_benchmark"}. + lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG") + + pio_options = PLATFORMIO_OPTIONS + if lib_config_json: + lib_config = json.loads(lib_config_json) + benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}" + # These defines must be global (not just in library.json) because + # benchmark.h uses #ifdef CODSPEED_ENABLED to switch benchmark + # registration to CodSpeed-instrumented variants, and + # CODSPEED_ROOT_DIR is used to display relative file paths in reports. + project_root = Path(__file__).resolve().parent.parent + codspeed_flags = [ + "-DNDEBUG", + "-DCODSPEED_ENABLED", + "-DCODSPEED_ANALYSIS", + f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"', + ] + pio_options = { + **PLATFORMIO_OPTIONS, + "build_flags": PLATFORMIO_OPTIONS["build_flags"] + codspeed_flags, + } + else: + benchmark_lib = PLATFORMIO_GOOGLE_BENCHMARK_LIB + + return build_and_run( + selected_components=selected_components, + tests_dir=BENCHMARKS_DIR, + codegen_components=BENCHMARK_CODEGEN_COMPONENTS, + config_prefix="cppbench", + friendly_name="CPP Benchmarks", + libraries=benchmark_lib, + platformio_options=pio_options, + main_entry="main.cpp", + label="benchmarks", + build_only=build_only, + extra_include_dirs=[CORE_BENCHMARKS_DIR], + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Build and run C++ benchmarks for ESPHome components." + ) + parser.add_argument( + "components", + nargs="*", + help="List of components to benchmark (must have files in tests/benchmarks/components/).", + ) + parser.add_argument( + "--all", + action="store_true", + help="Benchmark all components with benchmark files.", + ) + parser.add_argument( + "--build-only", + action="store_true", + help="Only build, print binary path without running.", + ) + + args = parser.parse_args() + + if args.all: + # Find all component directories that have .cpp files + components: list[str] = ( + sorted( + d.name + for d in BENCHMARKS_DIR.iterdir() + if d.is_dir() + and d.name != "__pycache__" + and (any(d.glob("*.cpp")) or any(d.glob("*.h"))) + ) + if BENCHMARKS_DIR.is_dir() + else [] + ) + else: + components: list[str] = args.components + + sys.exit(run_benchmarks(components, build_only=args.build_only)) + + +if __name__ == "__main__": + main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 6808a3cf6c9..ad08f8dce56 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -381,6 +381,63 @@ def determine_cpp_unit_tests( return (False, get_cpp_changed_components(cpp_files)) +# Paths within tests/benchmarks/ that contain component benchmark files +BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" + +# Files that, when changed, should trigger benchmark runs +BENCHMARK_INFRASTRUCTURE_FILES = frozenset( + { + "script/cpp_benchmark.py", + "script/test_helpers.py", + "script/setup_codspeed_lib.py", + } +) + + +def should_run_benchmarks(branch: str | None = None) -> bool: + """Determine if C++ benchmarks should run based on changed files. + + Benchmarks run when any of the following conditions are met: + + 1. Core C++ files changed (esphome/core/*) + 2. A directly changed component has benchmark files (no dependency expansion) + 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, + script/test_helpers.py, script/setup_codspeed_lib.py) + + Unlike unit tests, benchmarks do NOT expand to dependent components. + Changing ``sensor`` does not trigger ``api`` benchmarks just because + api depends on sensor. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if benchmarks should run, False otherwise. + """ + files = changed_files(branch) + if core_changed(files): + return True + + # Check if benchmark infrastructure changed + if any( + f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES + for f in files + ): + return True + + # Check if any directly changed component has benchmarks + benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH + if not benchmarks_dir.is_dir(): + return False + benchmarked_components = { + d.name + for d in benchmarks_dir.iterdir() + if d.is_dir() and (any(d.glob("*.cpp")) or any(d.glob("*.h"))) + } + # Only direct changes — no dependency expansion + return any(get_component_from_path(f) in benchmarked_components for f in files) + + def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: """Check if a changed file ends with any of the specified extensions.""" return any(file.endswith(extensions) for file in changed_files(branch)) @@ -804,6 +861,9 @@ def main() -> None: # Determine which C++ unit tests to run cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) + # Determine if benchmarks should run + run_benchmarks = should_run_benchmarks(args.branch) + # Split components into batches for CI testing # This intelligently groups components with similar bus configurations component_test_batches: list[str] @@ -856,6 +916,7 @@ def main() -> None: "cpp_unit_tests_run_all": cpp_run_all, "cpp_unit_tests_components": cpp_components, "component_test_batches": component_test_batches, + "benchmarks": run_benchmarks, } # Output as JSON diff --git a/script/setup_codspeed_lib.py b/script/setup_codspeed_lib.py new file mode 100755 index 00000000000..959c89d05b6 --- /dev/null +++ b/script/setup_codspeed_lib.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Set up CodSpeed's google_benchmark fork as a PlatformIO library. + +CodSpeed requires their codspeed-cpp fork for CPU simulation instrumentation. +This script clones the repo and assembles a flat PlatformIO-compatible library +by combining google_benchmark sources, codspeed core, and instrument-hooks. + +PlatformIO quirks addressed: + - .cc files renamed to .cpp (PlatformIO ignores .cc) + - All sources merged into one src/ dir (PlatformIO can't compile from + multiple source directories in a single library) + - library.json created with required CodSpeed preprocessor defines + +Usage: + python script/setup_codspeed_lib.py [--output-dir DIR] + +Prints JSON to stdout with lib_path for cpp_benchmark.py. +Git output goes to stderr. + +See https://codspeed.io/docs/benchmarks/cpp#custom-build-systems +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys + +# Pin to a specific release for reproducibility +CODSPEED_CPP_REPO = "https://github.com/CodSpeedHQ/codspeed-cpp.git" +CODSPEED_CPP_SHA = "e633aca00da3d0ad14e7bf424d9cb47165a29028" # v2.1.0 + +DEFAULT_OUTPUT_DIR = "/tmp/codspeed-cpp" + +# Well-known paths within the codspeed-cpp repository +GOOGLE_BENCHMARK_SUBDIR = "google_benchmark" +CORE_SUBDIR = "core" +INSTRUMENT_HOOKS_SUBDIR = Path(CORE_SUBDIR) / "instrument-hooks" +INSTRUMENT_HOOKS_INCLUDES = INSTRUMENT_HOOKS_SUBDIR / "includes" +INSTRUMENT_HOOKS_DIST = INSTRUMENT_HOOKS_SUBDIR / "dist" / "core.c" +CORE_CMAKE = Path(CORE_SUBDIR) / "CMakeLists.txt" + + +def _git(args: list[str], **kwargs: object) -> None: + """Run a git command, sending output to stderr.""" + subprocess.run( + ["git", *args], + check=True, + stdout=kwargs.pop("stdout", sys.stderr), + stderr=kwargs.pop("stderr", sys.stderr), + **kwargs, + ) + + +def _clone_repo(output_dir: Path) -> None: + """Shallow-clone codspeed-cpp at the pinned SHA with submodules.""" + output_dir.mkdir(parents=True, exist_ok=True) + _git(["init", str(output_dir)]) + _git(["-C", str(output_dir), "remote", "add", "origin", CODSPEED_CPP_REPO]) + _git(["-C", str(output_dir), "fetch", "--depth", "1", "origin", CODSPEED_CPP_SHA]) + _git( + ["-C", str(output_dir), "checkout", "FETCH_HEAD"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + _git( + [ + "-C", + str(output_dir), + "submodule", + "update", + "--init", + "--recursive", + "--depth", + "1", + ] + ) + + +def _read_codspeed_version(cmake_path: Path) -> str: + """Extract CODSPEED_VERSION from core/CMakeLists.txt.""" + if not cmake_path.exists(): + return "0.0.0" + for line in cmake_path.read_text().splitlines(): + if line.startswith("set(CODSPEED_VERSION"): + return line.split()[1].rstrip(")") + return "0.0.0" + + +def _rename_cc_to_cpp(src_dir: Path) -> None: + """Rename .cc files to .cpp so PlatformIO compiles them.""" + for cc_file in src_dir.glob("*.cc"): + cpp_file = cc_file.with_suffix(".cpp") + if not cpp_file.exists(): + cc_file.rename(cpp_file) + + +def _copy_if_missing(src: Path, dest: Path) -> None: + """Copy a file only if the destination doesn't already exist.""" + if not dest.exists(): + shutil.copy2(src, dest) + + +def _merge_codspeed_core_into_lib(core_src: Path, lib_src: Path) -> None: + """Copy codspeed core sources into the benchmark library src/. + + .cpp files get a ``codspeed_`` prefix to avoid name collisions with + google_benchmark's own sources. .h files keep their original names + since they're referenced by ``#include "walltime.h"`` etc. + """ + for src_file in core_src.iterdir(): + if src_file.suffix == ".cpp": + _copy_if_missing(src_file, lib_src / f"codspeed_{src_file.name}") + elif src_file.suffix == ".h": + _copy_if_missing(src_file, lib_src / src_file.name) + + +def _write_library_json( + benchmark_dir: Path, + core_include: Path, + hooks_include: Path, + version: str, + project_root: Path, +) -> None: + """Write a PlatformIO library.json with CodSpeed build flags.""" + library_json = { + "name": "benchmark", + "version": "0.0.0", + "build": { + "flags": [ + f"-I{core_include}", + f"-I{hooks_include}", + # google benchmark build flags + # -O2 is critical: without it, instrument_hooks_start_benchmark_inline + # doesn't get inlined and shows up as overhead in profiles + "-O2", + "-DNDEBUG", + "-DHAVE_STD_REGEX", + "-DHAVE_STEADY_CLOCK", + "-DBENCHMARK_STATIC_DEFINE", + # CodSpeed instrumentation flags + # https://codspeed.io/docs/benchmarks/cpp#custom-build-systems + "-DCODSPEED_ENABLED", + "-DCODSPEED_ANALYSIS", + f'-DCODSPEED_VERSION=\\"{version}\\"', + f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"', + '-DCODSPEED_MODE_DISPLAY=\\"simulation\\"', + ], + "includeDir": "include", + }, + } + (benchmark_dir / "library.json").write_text( + json.dumps(library_json, indent=2) + "\n" + ) + + +def setup_codspeed_lib(output_dir: Path) -> None: + """Clone codspeed-cpp and assemble a flat PlatformIO library. + + The resulting library at ``output_dir/google_benchmark/`` contains: + - google_benchmark sources (.cc renamed to .cpp) + - codspeed core sources (prefixed ``codspeed_``) + - instrument-hooks C source (as ``instrument_hooks.c``) + - library.json with all required CodSpeed defines + + Args: + output_dir: Directory to clone the repository into + """ + if not (output_dir / ".git").exists(): + _clone_repo(output_dir) + else: + # Verify the existing checkout matches the pinned SHA + result = subprocess.run( + ["git", "-C", str(output_dir), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or result.stdout.strip() != CODSPEED_CPP_SHA: + print( + f"Stale codspeed-cpp checkout, re-cloning at {CODSPEED_CPP_SHA}", + file=sys.stderr, + ) + shutil.rmtree(output_dir) + _clone_repo(output_dir) + + benchmark_dir = output_dir / GOOGLE_BENCHMARK_SUBDIR + lib_src = benchmark_dir / "src" + core_dir = output_dir / CORE_SUBDIR + core_include = core_dir / "include" + hooks_include = output_dir / INSTRUMENT_HOOKS_INCLUDES + hooks_dist_c = output_dir / INSTRUMENT_HOOKS_DIST + project_root = Path(__file__).resolve().parent.parent + + # 1. Rename .cc → .cpp (PlatformIO doesn't compile .cc) + _rename_cc_to_cpp(lib_src) + + # 2. Merge codspeed core sources into the library + _merge_codspeed_core_into_lib(core_dir / "src", lib_src) + + # 3. Copy instrument-hooks C source (provides instrument_hooks_* symbols) + if hooks_dist_c.exists(): + _copy_if_missing(hooks_dist_c, lib_src / "instrument_hooks.c") + + # 4. Write library.json + version = _read_codspeed_version(output_dir / CORE_CMAKE) + _write_library_json( + benchmark_dir, core_include, hooks_include, version, project_root + ) + + # Output JSON config for cpp_benchmark.py + print(json.dumps({"lib_path": str(benchmark_dir)})) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output-dir", + type=Path, + default=Path(DEFAULT_OUTPUT_DIR), + help=f"Directory to clone codspeed-cpp into (default: {DEFAULT_OUTPUT_DIR})", + ) + args = parser.parse_args() + setup_codspeed_lib(args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/tests/benchmarks/components/.gitignore b/tests/benchmarks/components/.gitignore new file mode 100644 index 00000000000..163bec7b80a --- /dev/null +++ b/tests/benchmarks/components/.gitignore @@ -0,0 +1,2 @@ +/.esphome/ +/secrets.yaml diff --git a/tests/benchmarks/components/api/bench_proto_decode.cpp b/tests/benchmarks/components/api/bench_proto_decode.cpp new file mode 100644 index 00000000000..113201dd8a1 --- /dev/null +++ b/tests/benchmarks/components/api/bench_proto_decode.cpp @@ -0,0 +1,93 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +// Without this, the ~60ns per-iteration valgrind start/stop cost dominates +// sub-microsecond benchmarks. +static constexpr int kInnerIterations = 2000; + +// Helper: encode a message into a buffer and return it. +// Benchmarks encode once in setup, then decode the resulting bytes in a loop. +// This keeps decode benchmarks in sync with the actual protobuf schema — +// hand-encoded byte arrays would silently break when fields change. +template static APIBuffer encode_message(const T &msg) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + return buffer; +} + +// --- HelloRequest decode (string + varint fields) --- + +static void Decode_HelloRequest(benchmark::State &state) { + HelloRequest source; + source.client_info = StringRef::from_lit("aioesphomeapi"); + source.api_version_major = 1; + source.api_version_minor = 10; + auto encoded = encode_message(source); + + for (auto _ : state) { + HelloRequest msg; + for (int i = 0; i < kInnerIterations; i++) { + msg.decode(encoded.data(), encoded.size()); + } + benchmark::DoNotOptimize(msg.api_version_major); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_HelloRequest); + +// --- SwitchCommandRequest decode (simple command) --- + +static void Decode_SwitchCommandRequest(benchmark::State &state) { + SwitchCommandRequest source; + source.key = 0x12345678; + source.state = true; + auto encoded = encode_message(source); + + for (auto _ : state) { + SwitchCommandRequest msg; + for (int i = 0; i < kInnerIterations; i++) { + msg.decode(encoded.data(), encoded.size()); + } + benchmark::DoNotOptimize(msg.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_SwitchCommandRequest); + +// --- LightCommandRequest decode (complex command with many fields) --- + +static void Decode_LightCommandRequest(benchmark::State &state) { + LightCommandRequest source; + source.key = 0x11223344; + source.has_state = true; + source.state = true; + source.has_brightness = true; + source.brightness = 0.8f; + source.has_rgb = true; + source.red = 1.0f; + source.green = 0.5f; + source.blue = 0.2f; + source.has_effect = true; + source.effect = StringRef::from_lit("rainbow"); + auto encoded = encode_message(source); + + for (auto _ : state) { + LightCommandRequest msg; + for (int i = 0; i < kInnerIterations; i++) { + msg.decode(encoded.data(), encoded.size()); + } + benchmark::DoNotOptimize(msg.brightness); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_LightCommandRequest); + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp new file mode 100644 index 00000000000..656c1e17dba --- /dev/null +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -0,0 +1,298 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +// Without this, the ~60ns per-iteration valgrind start/stop cost dominates +// sub-microsecond benchmarks. +static constexpr int kInnerIterations = 2000; + +// --- SensorStateResponse (highest frequency message) --- + +static void Encode_SensorStateResponse(benchmark::State &state) { + APIBuffer buffer; + SensorStateResponse msg; + msg.key = 0x12345678; + msg.state = 23.5f; + msg.missing_state = false; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_SensorStateResponse); + +static void CalculateSize_SensorStateResponse(benchmark::State &state) { + SensorStateResponse msg; + msg.key = 0x12345678; + msg.state = 23.5f; + msg.missing_state = false; + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_SensorStateResponse); + +// Steady state: buffer already allocated from previous iteration +static void CalcAndEncode_SensorStateResponse(benchmark::State &state) { + APIBuffer buffer; + SensorStateResponse msg; + msg.key = 0x12345678; + msg.state = 23.5f; + msg.missing_state = false; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_SensorStateResponse); + +// Cold path: fresh buffer each iteration (measures heap allocation cost). +// Inner loop still needed to amortize CodSpeed instrumentation overhead. +// Each inner iteration creates a fresh buffer, so this measures +// alloc+calc+encode per item. +static void CalcAndEncode_SensorStateResponse_Fresh(benchmark::State &state) { + SensorStateResponse msg; + msg.key = 0x12345678; + msg.state = 23.5f; + msg.missing_state = false; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_SensorStateResponse_Fresh); + +// --- BinarySensorStateResponse --- + +static void Encode_BinarySensorStateResponse(benchmark::State &state) { + APIBuffer buffer; + BinarySensorStateResponse msg; + msg.key = 0xAABBCCDD; + msg.state = true; + msg.missing_state = false; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_BinarySensorStateResponse); + +// --- HelloResponse (string fields) --- + +static void Encode_HelloResponse(benchmark::State &state) { + APIBuffer buffer; + HelloResponse msg; + msg.api_version_major = 1; + msg.api_version_minor = 10; + msg.server_info = StringRef::from_lit("esphome v2026.3.0"); + msg.name = StringRef::from_lit("living-room-sensor"); + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_HelloResponse); + +// --- LightStateResponse (complex multi-field message) --- + +static void Encode_LightStateResponse(benchmark::State &state) { + APIBuffer buffer; + LightStateResponse msg; + msg.key = 0x11223344; + msg.state = true; + msg.brightness = 0.8f; + msg.color_mode = enums::COLOR_MODE_RGB_WHITE; + msg.color_brightness = 1.0f; + msg.red = 1.0f; + msg.green = 0.5f; + msg.blue = 0.2f; + msg.white = 0.0f; + msg.color_temperature = 4000.0f; + msg.cold_white = 0.0f; + msg.warm_white = 0.0f; + msg.effect = StringRef::from_lit("rainbow"); + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_LightStateResponse); + +static void CalculateSize_LightStateResponse(benchmark::State &state) { + LightStateResponse msg; + msg.key = 0x11223344; + msg.state = true; + msg.brightness = 0.8f; + msg.color_mode = enums::COLOR_MODE_RGB_WHITE; + msg.color_brightness = 1.0f; + msg.red = 1.0f; + msg.green = 0.5f; + msg.blue = 0.2f; + msg.white = 0.0f; + msg.color_temperature = 4000.0f; + msg.cold_white = 0.0f; + msg.warm_white = 0.0f; + msg.effect = StringRef::from_lit("rainbow"); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_LightStateResponse); + +// --- DeviceInfoResponse (nested submessages: 20 devices + 20 areas) --- + +static DeviceInfoResponse make_device_info_response() { + DeviceInfoResponse msg; + msg.name = StringRef::from_lit("living-room-sensor"); + msg.mac_address = StringRef::from_lit("AA:BB:CC:DD:EE:FF"); + msg.esphome_version = StringRef::from_lit("2026.3.0"); + msg.compilation_time = StringRef::from_lit("Mar 16 2026, 12:00:00"); + msg.model = StringRef::from_lit("esp32-poe-iso"); + msg.manufacturer = StringRef::from_lit("Olimex"); + msg.friendly_name = StringRef::from_lit("Living Room Sensor"); +#ifdef USE_DEVICES + for (uint32_t i = 0; i < ESPHOME_DEVICE_COUNT && i < 20; i++) { + msg.devices[i].device_id = i + 1; + msg.devices[i].name = StringRef::from_lit("device"); + msg.devices[i].area_id = (i % 20) + 1; + } +#endif +#ifdef USE_AREAS + for (uint32_t i = 0; i < ESPHOME_AREA_COUNT && i < 20; i++) { + msg.areas[i].area_id = i + 1; + msg.areas[i].name = StringRef::from_lit("area"); + } +#endif + return msg; +} + +static void CalculateSize_DeviceInfoResponse(benchmark::State &state) { + auto msg = make_device_info_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_DeviceInfoResponse); + +static void Encode_DeviceInfoResponse(benchmark::State &state) { + auto msg = make_device_info_response(); + APIBuffer buffer; + uint32_t total_size = msg.calculate_size(); + buffer.resize(total_size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_DeviceInfoResponse); + +// Steady state: buffer already allocated from previous iteration +static void CalcAndEncode_DeviceInfoResponse(benchmark::State &state) { + auto msg = make_device_info_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_DeviceInfoResponse); + +// Cold path: fresh buffer each iteration (measures heap allocation cost). +// Inner loop still needed to amortize CodSpeed instrumentation overhead. +// Each inner iteration creates a fresh buffer, so this measures +// alloc+calc+encode per item. +static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) { + auto msg = make_device_info_response(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_DeviceInfoResponse_Fresh); + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_proto_varint.cpp b/tests/benchmarks/components/api/bench_proto_varint.cpp new file mode 100644 index 00000000000..0b5ccc2b7de --- /dev/null +++ b/tests/benchmarks/components/api/bench_proto_varint.cpp @@ -0,0 +1,133 @@ +#include + +#include "esphome/components/api/proto.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +// Without this, the ~60ns per-iteration valgrind start/stop cost dominates +// sub-microsecond benchmarks. +static constexpr int kInnerIterations = 2000; + +// --- ProtoVarInt::parse() benchmarks --- + +static void ProtoVarInt_Parse_SingleByte(benchmark::State &state) { + uint8_t buf[] = {0x42}; // value = 66 + + for (auto _ : state) { + ProtoVarIntResult result{}; + for (int i = 0; i < kInnerIterations; i++) { + result = ProtoVarInt::parse(buf, sizeof(buf)); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ProtoVarInt_Parse_SingleByte); + +static void ProtoVarInt_Parse_TwoByte(benchmark::State &state) { + uint8_t buf[] = {0x80, 0x01}; // value = 128 + + for (auto _ : state) { + ProtoVarIntResult result{}; + for (int i = 0; i < kInnerIterations; i++) { + result = ProtoVarInt::parse(buf, sizeof(buf)); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ProtoVarInt_Parse_TwoByte); + +static void ProtoVarInt_Parse_FiveByte(benchmark::State &state) { + uint8_t buf[] = {0xFF, 0xFF, 0xFF, 0xFF, 0x0F}; + + for (auto _ : state) { + ProtoVarIntResult result{}; + for (int i = 0; i < kInnerIterations; i++) { + result = ProtoVarInt::parse(buf, sizeof(buf)); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ProtoVarInt_Parse_FiveByte); + +// --- Varint encoding benchmarks --- + +static void Encode_Varint_Small(benchmark::State &state) { + APIBuffer buffer; + buffer.resize(16); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + writer.encode_varint_raw(42); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_Varint_Small); + +static void Encode_Varint_Large(benchmark::State &state) { + APIBuffer buffer; + buffer.resize(16); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + writer.encode_varint_raw(300); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_Varint_Large); + +static void Encode_Varint_MaxUint32(benchmark::State &state) { + APIBuffer buffer; + buffer.resize(16); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + writer.encode_varint_raw(0xFFFFFFFF); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_Varint_MaxUint32); + +// --- ProtoSize::varint() benchmarks --- + +static void ProtoSize_Varint_Small(benchmark::State &state) { + // Use varying input to prevent constant folding. + // Values 0-127 all take 1 byte but the compiler can't prove that. + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += ProtoSize::varint(static_cast(i) & 0x7F); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ProtoSize_Varint_Small); + +static void ProtoSize_Varint_Large(benchmark::State &state) { + // Use varying input to prevent constant folding. + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += ProtoSize::varint(0xFFFF0000 | static_cast(i)); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ProtoSize_Varint_Large); + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/benchmark.yaml b/tests/benchmarks/components/api/benchmark.yaml new file mode 100644 index 00000000000..bfc24d7440d --- /dev/null +++ b/tests/benchmarks/components/api/benchmark.yaml @@ -0,0 +1,114 @@ +# Components needed for API protobuf benchmarks. +# Merged into the base config before validation so all +# dependencies get proper defaults. +# +# esphome: sub-keys are merged into the base config. +esphome: + areas: + - id: area_1 + name: "Area 1" + - id: area_2 + name: "Area 2" + - id: area_3 + name: "Area 3" + - id: area_4 + name: "Area 4" + - id: area_5 + name: "Area 5" + - id: area_6 + name: "Area 6" + - id: area_7 + name: "Area 7" + - id: area_8 + name: "Area 8" + - id: area_9 + name: "Area 9" + - id: area_10 + name: "Area 10" + - id: area_11 + name: "Area 11" + - id: area_12 + name: "Area 12" + - id: area_13 + name: "Area 13" + - id: area_14 + name: "Area 14" + - id: area_15 + name: "Area 15" + - id: area_16 + name: "Area 16" + - id: area_17 + name: "Area 17" + - id: area_18 + name: "Area 18" + - id: area_19 + name: "Area 19" + - id: area_20 + name: "Area 20" + devices: + - id: device_1 + name: "Device 1" + area_id: area_1 + - id: device_2 + name: "Device 2" + area_id: area_2 + - id: device_3 + name: "Device 3" + area_id: area_3 + - id: device_4 + name: "Device 4" + area_id: area_4 + - id: device_5 + name: "Device 5" + area_id: area_5 + - id: device_6 + name: "Device 6" + area_id: area_6 + - id: device_7 + name: "Device 7" + area_id: area_7 + - id: device_8 + name: "Device 8" + area_id: area_8 + - id: device_9 + name: "Device 9" + area_id: area_9 + - id: device_10 + name: "Device 10" + area_id: area_10 + - id: device_11 + name: "Device 11" + area_id: area_11 + - id: device_12 + name: "Device 12" + area_id: area_12 + - id: device_13 + name: "Device 13" + area_id: area_13 + - id: device_14 + name: "Device 14" + area_id: area_14 + - id: device_15 + name: "Device 15" + area_id: area_15 + - id: device_16 + name: "Device 16" + area_id: area_16 + - id: device_17 + name: "Device 17" + area_id: area_17 + - id: device_18 + name: "Device 18" + area_id: area_18 + - id: device_19 + name: "Device 19" + area_id: area_19 + - id: device_20 + name: "Device 20" + area_id: area_20 + +api: +sensor: +binary_sensor: +light: +switch: diff --git a/tests/benchmarks/components/main.cpp b/tests/benchmarks/components/main.cpp new file mode 100644 index 00000000000..9bc0c31a159 --- /dev/null +++ b/tests/benchmarks/components/main.cpp @@ -0,0 +1,42 @@ +#include + +#include "esphome/components/logger/logger.h" + +/* +This special main.cpp provides the entry point for Google Benchmark. +It replaces the default ESPHome main with a benchmark runner. + +*/ + +// Auto generated code by esphome +// ========== AUTO GENERATED INCLUDE BLOCK BEGIN =========== +// ========== AUTO GENERATED INCLUDE BLOCK END =========== + +void original_setup() { + // Code-generated App initialization (pre_setup, area/device registration, etc.) + + // ========== AUTO GENERATED CODE BEGIN =========== + // =========== AUTO GENERATED CODE END ============ +} + +void setup() { + // Run auto-generated initialization (App.pre_setup, area/device registration, + // looping_components_.init, etc.) so benchmarks that use App work correctly. + original_setup(); + + // Log functions call global_logger->log_vprintf_() without a null check, + // so we must set up a Logger before any test that triggers logging. + static esphome::logger::Logger test_logger(0); + test_logger.set_log_level(ESPHOME_LOG_LEVEL); + test_logger.pre_setup(); + + int argc = 1; + char arg0[] = "benchmark"; + char *argv[] = {arg0, nullptr}; + ::benchmark::Initialize(&argc, argv); + ::benchmark::RunSpecifiedBenchmarks(); + ::benchmark::Shutdown(); + exit(0); +} + +void loop() {} diff --git a/tests/benchmarks/core/bench_application_loop.cpp b/tests/benchmarks/core/bench_application_loop.cpp new file mode 100644 index 00000000000..dde78ae7390 --- /dev/null +++ b/tests/benchmarks/core/bench_application_loop.cpp @@ -0,0 +1,22 @@ +#include + +#include "esphome/core/application.h" + +namespace esphome::benchmarks { + +// Benchmark Application::loop() with no registered components. +// App is initialized by original_setup() in main.cpp (code-generated +// pre_setup, area/device registration, looping_components_.init). +// This measures the baseline overhead of the main loop: scheduler, +// timing, before/after loop tasks, and yield_with_select_. +static void ApplicationLoop_Empty(benchmark::State &state) { + // Set loop interval to 0 so yield_with_select_ returns immediately + // instead of sleeping. This benchmarks the loop overhead, not the sleep. + App.set_loop_interval(0); + for (auto _ : state) { + App.loop(); + } +} +BENCHMARK(ApplicationLoop_Empty); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp new file mode 100644 index 00000000000..c6e1e6930eb --- /dev/null +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -0,0 +1,41 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +// Without this, the ~60ns per-iteration valgrind start/stop cost dominates +// sub-microsecond benchmarks. +static constexpr int kInnerIterations = 2000; + +// --- random_float() --- +// Ported from ol.yaml:148 "Random Float Benchmark" + +static void RandomFloat(benchmark::State &state) { + for (auto _ : state) { + float result = 0.0f; + for (int i = 0; i < kInnerIterations; i++) { + result += random_float(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(RandomFloat); + +// --- random_uint32() --- + +static void RandomUint32(benchmark::State &state) { + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += random_uint32(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(RandomUint32); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/core/bench_logger.cpp b/tests/benchmarks/core/bench_logger.cpp new file mode 100644 index 00000000000..b7e9a1c4eac --- /dev/null +++ b/tests/benchmarks/core/bench_logger.cpp @@ -0,0 +1,54 @@ +#include + +#include "esphome/core/log.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +// Without this, the ~60ns per-iteration valgrind start/stop cost dominates +// sub-microsecond benchmarks. +static constexpr int kInnerIterations = 2000; + +static const char *const TAG = "bench"; + +// --- Log a message with no format specifiers (fastest path) --- + +static void Logger_NoFormat(benchmark::State &state) { + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ESP_LOGW(TAG, "Something happened"); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Logger_NoFormat); + +// --- Log a message with 3 uint32_t format specifiers --- + +static void Logger_3Uint32(benchmark::State &state) { + uint32_t a = 12345, b = 67890, c = 99999; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ESP_LOGW(TAG, "Values: %" PRIu32 " %" PRIu32 " %" PRIu32, a, b, c); + } + benchmark::DoNotOptimize(a); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Logger_3Uint32); + +// --- Log a message with 3 floats (common for sensor values) --- + +static void Logger_3Float(benchmark::State &state) { + float temp = 23.456f, humidity = 67.89f, pressure = 1013.25f; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ESP_LOGW(TAG, "Sensor: %.2f %.1f %.2f", temp, humidity, pressure); + } + benchmark::DoNotOptimize(temp); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Logger_3Float); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp new file mode 100644 index 00000000000..764f17ed735 --- /dev/null +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -0,0 +1,133 @@ +#include + +#include "esphome/core/scheduler.h" +#include "esphome/core/hal.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +// Without this, the ~60ns per-iteration valgrind start/stop cost dominates +// sub-microsecond benchmarks. +static constexpr int kInnerIterations = 2000; + +// --- Scheduler fast path: no work to do --- + +static void Scheduler_Call_NoWork(benchmark::State &state) { + Scheduler scheduler; + uint32_t now = millis(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + scheduler.call(now); + } + benchmark::DoNotOptimize(now); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Call_NoWork); + +// --- Scheduler with timers: call() when timers exist but aren't due --- + +static void Scheduler_Call_TimersNotDue(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Add some timeouts far in the future + for (int i = 0; i < 10; i++) { + scheduler.set_timeout(&dummy_component, static_cast(i), 1000000, []() {}); + } + scheduler.process_to_add(); + + uint32_t now = millis(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + scheduler.call(now); + } + benchmark::DoNotOptimize(now); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Call_TimersNotDue); + +// --- Scheduler with 5 intervals firing every call --- + +static void Scheduler_Call_5IntervalsFiring(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + int fire_count = 0; + + // Benchmarks the heap-based scheduler dispatch with 5 callbacks firing. + // Uses monotonically increasing fake time so intervals reliably fire every call. + // USE_BENCHMARK ifdef in component.h disables WarnIfComponentBlockingGuard + // (fake now > real millis() would cause underflow in finish()). + // interval=0 would cause an infinite loop (reschedules at same now). + for (int i = 0; i < 5; i++) { + scheduler.set_interval(&dummy_component, static_cast(i), 1, [&fire_count]() { fire_count++; }); + } + scheduler.process_to_add(); + + uint32_t now = millis() + 100; + + for (auto _ : state) { + scheduler.call(now); + now++; + benchmark::DoNotOptimize(fire_count); + } +} +BENCHMARK(Scheduler_Call_5IntervalsFiring); + +// --- Scheduler: set_timeout registration --- + +static void Scheduler_SetTimeout(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(i % 5), 1000, []() {}); + } + scheduler.process_to_add(); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_SetTimeout); + +// --- Scheduler: set_interval registration --- + +static void Scheduler_SetInterval(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_interval(&dummy_component, static_cast(i % 5), 1000, []() {}); + } + scheduler.process_to_add(); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_SetInterval); + +// --- Scheduler: defer registration (set_timeout with delay=0) --- + +static void Scheduler_Defer(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // defer() is Component::defer which calls set_timeout(delay=0). + // Call set_timeout directly since defer() is protected. + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(i % 5), 0, []() {}); + } + scheduler.process_to_add(); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Defer); + +} // namespace esphome::benchmarks diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 5c81ad374b1..29535d1fd38 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1821,3 +1821,151 @@ def test_component_batching_beta_branch_40_per_batch( all_components.extend(batch_str.split()) assert len(all_components) == 120 assert set(all_components) == set(component_names) + + +# --- should_run_benchmarks tests --- + + +def test_should_run_benchmarks_core_change() -> None: + """Test benchmarks trigger on core C++ file changes.""" + with patch.object( + determine_jobs, "changed_files", return_value=["esphome/core/scheduler.cpp"] + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_should_run_benchmarks_core_header_change() -> None: + """Test benchmarks trigger on core header changes.""" + with patch.object( + determine_jobs, "changed_files", return_value=["esphome/core/helpers.h"] + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_should_run_benchmarks_benchmark_infra_change() -> None: + """Test benchmarks trigger on benchmark infrastructure changes.""" + for infra_file in [ + "script/cpp_benchmark.py", + "script/test_helpers.py", + "script/setup_codspeed_lib.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[infra_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {infra_file}" + ) + + +def test_should_run_benchmarks_benchmark_file_change() -> None: + """Test benchmarks trigger on benchmark file changes.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["tests/benchmarks/components/api/bench_proto_encode.cpp"], + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_should_run_benchmarks_core_benchmark_file_change() -> None: + """Test benchmarks trigger on core benchmark file changes.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["tests/benchmarks/core/bench_scheduler.cpp"], + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None: + """Test benchmarks trigger when a benchmarked component changes.""" + # Create a fake benchmarks directory with an 'api' component + benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api" + benchmarks_dir.mkdir(parents=True) + (benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark") + + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/api/proto.h"], + ), + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch.object( + determine_jobs, + "BENCHMARKS_COMPONENTS_PATH", + "tests/benchmarks/components", + ), + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_should_run_benchmarks_non_benchmarked_component_change( + tmp_path: Path, +) -> None: + """Test benchmarks do NOT trigger for non-benchmarked component changes.""" + # Create a fake benchmarks directory with only 'api' + benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api" + benchmarks_dir.mkdir(parents=True) + (benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark") + + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/sensor/__init__.py"], + ), + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch.object( + determine_jobs, + "BENCHMARKS_COMPONENTS_PATH", + "tests/benchmarks/components", + ), + ): + assert determine_jobs.should_run_benchmarks() is False + + +def test_should_run_benchmarks_no_dependency_expansion(tmp_path: Path) -> None: + """Test benchmarks do NOT expand to dependent components. + + Changing 'sensor' should not trigger 'api' benchmarks even if api + depends on sensor. This is intentional — benchmark runs should be + targeted to directly changed components only. + """ + benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api" + benchmarks_dir.mkdir(parents=True) + (benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark") + + with ( + patch.object( + determine_jobs, + "changed_files", + # sensor is a dependency of api, but benchmarks don't expand + return_value=["esphome/components/sensor/sensor.cpp"], + ), + patch.object(determine_jobs, "root_path", str(tmp_path)), + patch.object( + determine_jobs, + "BENCHMARKS_COMPONENTS_PATH", + "tests/benchmarks/components", + ), + ): + assert determine_jobs.should_run_benchmarks() is False + + +def test_should_run_benchmarks_unrelated_change() -> None: + """Test benchmarks do NOT trigger for unrelated changes.""" + with patch.object(determine_jobs, "changed_files", return_value=["README.md"]): + assert determine_jobs.should_run_benchmarks() is False + + +def test_should_run_benchmarks_no_changes() -> None: + """Test benchmarks do NOT trigger with no changes.""" + with patch.object(determine_jobs, "changed_files", return_value=[]): + assert determine_jobs.should_run_benchmarks() is False + + +def test_should_run_benchmarks_with_branch() -> None: + """Test should_run_benchmarks passes branch to changed_files.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_benchmarks("release") + mock_changed.assert_called_with("release") From 6b91df8d756686cbcd9e575733402be78f02d929 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:05:16 -1000 Subject: [PATCH 189/279] [esp32_ble][esp32_ble_server] Inline is_active/is_running and remove STL bloat (#14875) --- esphome/components/esp32_ble/ble.cpp | 2 -- esphome/components/esp32_ble/ble.h | 2 +- .../components/esp32_ble_server/ble_server.cpp | 16 +++++++--------- esphome/components/esp32_ble_server/ble_server.h | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fee1c546be4..317f8fd11b0 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -81,8 +81,6 @@ void ESP32BLE::disable() { this->state_ = BLE_COMPONENT_STATE_DISABLE; } -bool ESP32BLE::is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 752ddc9d1ff..82b27894611 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -135,7 +135,7 @@ class ESP32BLE : public Component { void enable(); void disable(); - bool is_active(); + ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; void dump_config() override; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index ecc53e197f2..be0691dc065 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #include @@ -39,16 +38,17 @@ void BLEServer::loop() { case RUNNING: { // Start all services that are pending to start if (!this->services_to_start_.empty()) { - for (auto &service : this->services_to_start_) { + size_t write_idx = 0; + for (auto *service : this->services_to_start_) { if (service->is_created()) { service->start(); // Needs to be called once per characteristic in the service } + // Remove services that have started or are starting + if (!service->is_starting() && !service->is_running()) { + this->services_to_start_[write_idx++] = service; + } } - // Remove services that have been started - this->services_to_start_.erase( - std::remove_if(this->services_to_start_.begin(), this->services_to_start_.end(), - [](BLEService *service) { return service->is_starting() || service->is_running(); }), - this->services_to_start_.end()); + this->services_to_start_.erase(this->services_to_start_.begin() + write_idx, this->services_to_start_.end()); } break; } @@ -91,8 +91,6 @@ void BLEServer::loop() { } } -bool BLEServer::is_running() { return this->parent_->is_active() && this->state_ == RUNNING; } - bool BLEServer::can_proceed() { return this->is_running() || !this->parent_->is_active(); } void BLEServer::restart_advertising_() { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index ff7e0044e4c..1b419d2ee45 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -32,7 +32,7 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv float get_setup_priority() const override; bool can_proceed() override; - bool is_running(); + ESPHOME_ALWAYS_INLINE bool is_running() { return this->parent_->is_active() && this->state_ == RUNNING; } void set_manufacturer_data(const std::vector &data) { this->manufacturer_data_ = data; From 77b7201eb88434531cba130c2772c9d40623b833 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:08:45 -1000 Subject: [PATCH 190/279] [ci] Run CodSpeed benchmarks on push to dev for baseline (#14899) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1926ad5bf4d..0a03579abc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,7 +316,9 @@ jobs: needs: - common - determine-jobs - if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true' + if: >- + (github.event_name == 'push' && github.ref_name == 'dev') || + (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From f3409acfa85a8bd7f7cda4d1a76a6deb07f4a0c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:08:58 -1000 Subject: [PATCH 191/279] [core] Document EventPool sizing requirement with LockFreeQueue (#14897) --- esphome/core/event_pool.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index 99541d4a179..ee8e81225a9 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -13,6 +13,14 @@ namespace esphome { // Events are allocated on first use and reused thereafter, growing to peak usage // @tparam T The type of objects managed by the pool (must have a release() method) // @tparam SIZE The maximum number of objects in the pool (1-255, limited by uint8_t) +// +// SIZING: When paired with a LockFreeQueue, the pool SIZE should be +// Q_SIZE - 1 (the queue's actual capacity, since the ring buffer reserves one slot). +// This ensures allocate() returns nullptr before push() can fail, which: +// - Prevents the allocate-succeeds-but-push-fails mismatch that permanently +// leaks a pool slot (the element is never returned to the pool) +// - Avoids needing release() on the producer path after a failed push(), +// preserving the SPSC contract on the internal free list template class EventPool { public: EventPool() : total_created_(0) {} From ece235218fe77108170d640b7320337d13c2260a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:27:46 -1000 Subject: [PATCH 192/279] [debug][bme680_bsec] Use fnv1_hash_extend to avoid temporary string allocations (#14876) --- esphome/components/bme680_bsec/bme680_bsec.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 75efb6835ab..bb0417b823d 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -521,7 +521,7 @@ int BME680BSECComponent::reinit_bsec_lib_() { } void BME680BSECComponent::load_state_() { - uint32_t hash = fnv1_hash("bme680_bsec_state_" + this->device_id_); + uint32_t hash = fnv1_hash_extend(fnv1_hash("bme680_bsec_state_"), this->device_id_); this->bsec_state_ = global_preferences->make_preference(hash, true); if (!this->bsec_state_.load(&this->bsec_state_data_)) { diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index aa379599c62..2e04090749e 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -49,7 +49,8 @@ static const size_t REBOOT_MAX_LEN = 24; void DebugComponent::on_shutdown() { auto *component = App.get_current_component(); char buffer[REBOOT_MAX_LEN]{}; - auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); + auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, + fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); if (component != nullptr) { strncpy(buffer, LOG_STR_ARG(component->get_component_log_str()), REBOOT_MAX_LEN - 1); buffer[REBOOT_MAX_LEN - 1] = '\0'; @@ -66,7 +67,8 @@ const char *DebugComponent::get_reset_reason_(std::spanmake_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); + auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, + fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); char reboot_source[REBOOT_MAX_LEN]{}; if (pref.load(&reboot_source)) { reboot_source[REBOOT_MAX_LEN - 1] = '\0'; From 83484a8828f2bb4936e41d069700c3b12cf2fa49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:38:41 -1000 Subject: [PATCH 193/279] [esp32_ble_server] Remove vestigial semaphore from BLECharacteristic (#14900) --- .../components/esp32_ble_server/ble_characteristic.cpp | 10 +--------- .../components/esp32_ble_server/ble_characteristic.h | 4 ---- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 1806354712d..aa82b773ba6 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -16,13 +16,9 @@ BLECharacteristic::~BLECharacteristic() { for (auto *descriptor : this->descriptors_) { delete descriptor; // NOLINT(cppcoreguidelines-owning-memory) } - vSemaphoreDelete(this->set_value_lock_); } BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) : uuid_(uuid) { - this->set_value_lock_ = xSemaphoreCreateBinary(); - xSemaphoreGive(this->set_value_lock_); - this->properties_ = (esp_gatt_char_prop_t) 0; this->set_broadcast_property((properties & PROPERTY_BROADCAST) != 0); @@ -35,11 +31,7 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } -void BLECharacteristic::set_value(std::vector &&buffer) { - xSemaphoreTake(this->set_value_lock_, 0L); - this->value_ = std::move(buffer); - xSemaphoreGive(this->set_value_lock_); -} +void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { this->set_value(std::vector(data)); // Delegate to move overload diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 72897d1dfbe..062052cdf8c 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -16,8 +16,6 @@ #include #include #include -#include -#include namespace esphome { namespace esp32_ble_server { @@ -84,8 +82,6 @@ class BLECharacteristic { uint16_t value_read_offset_{0}; std::vector value_; - SemaphoreHandle_t set_value_lock_; - std::vector descriptors_; struct ClientNotificationEntry { From 53bfb02a21487d70b4070a75a2709fcad2e3ff58 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:46:26 -0400 Subject: [PATCH 194/279] [sensor][ee895][hdc2010] Fix misc bugs found during component scan (#14890) --- esphome/components/ee895/ee895.cpp | 15 ++--- esphome/components/hdc2010/hdc2010.cpp | 76 +++++++++++--------------- esphome/components/sensor/__init__.py | 4 +- 3 files changed, 40 insertions(+), 55 deletions(-) diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 22f28be9bc3..93e5d4203b6 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -24,7 +24,7 @@ void EE895Component::setup() { this->read(serial_number, 20); crc16_check = (serial_number[19] << 8) + serial_number[18]; - if (crc16_check != calc_crc16_(serial_number, 19)) { + if (crc16_check != calc_crc16_(serial_number, 18)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); return; @@ -84,7 +84,7 @@ void EE895Component::write_command_(uint16_t addr, uint16_t reg_cnt) { address[2] = addr & 0xFF; address[3] = (reg_cnt >> 8) & 0xFF; address[4] = reg_cnt & 0xFF; - crc16 = calc_crc16_(address, 6); + crc16 = calc_crc16_(address, 5); address[5] = crc16 & 0xFF; address[6] = (crc16 >> 8) & 0xFF; this->write(address, 7); @@ -95,7 +95,7 @@ float EE895Component::read_float_() { uint8_t i2c_response[8]; this->read(i2c_response, 8); crc16_check = (i2c_response[7] << 8) + i2c_response[6]; - if (crc16_check != calc_crc16_(i2c_response, 7)) { + if (crc16_check != calc_crc16_(i2c_response, 6)) { this->error_code_ = CRC_CHECK_FAILED; this->status_set_warning(); return 0; @@ -107,12 +107,9 @@ float EE895Component::read_float_() { } uint16_t EE895Component::calc_crc16_(const uint8_t buf[], uint8_t len) { - uint8_t crc_check_buf[22]; - for (int i = 0; i < len; i++) { - crc_check_buf[i + 1] = buf[i]; - } - crc_check_buf[0] = this->address_; - return crc16(crc_check_buf, len); + uint8_t addr = this->address_; + uint16_t crc = crc16(&addr, 1); + return crc16(buf, len, crc); } } // namespace ee895 } // namespace esphome diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index c53fdb3f5be..0334b30eeca 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -7,50 +7,36 @@ namespace hdc2010 { static const char *const TAG = "hdc2010"; -static const uint8_t HDC2010_ADDRESS = 0x40; // 0b1000000 or 0b1000001 from datasheet -static const uint8_t HDC2010_CMD_CONFIGURATION_MEASUREMENT = 0x8F; -static const uint8_t HDC2010_CMD_START_MEASUREMENT = 0xF9; -static const uint8_t HDC2010_CMD_TEMPERATURE_LOW = 0x00; -static const uint8_t HDC2010_CMD_TEMPERATURE_HIGH = 0x01; -static const uint8_t HDC2010_CMD_HUMIDITY_LOW = 0x02; -static const uint8_t HDC2010_CMD_HUMIDITY_HIGH = 0x03; -static const uint8_t CONFIG = 0x0E; -static const uint8_t MEASUREMENT_CONFIG = 0x0F; +// Register addresses +static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; +static constexpr uint8_t REG_TEMPERATURE_HIGH = 0x01; +static constexpr uint8_t REG_HUMIDITY_LOW = 0x02; +static constexpr uint8_t REG_HUMIDITY_HIGH = 0x03; +static constexpr uint8_t REG_RESET_DRDY_INT_CONF = 0x0E; +static constexpr uint8_t REG_MEASUREMENT_CONF = 0x0F; + +// REG_MEASUREMENT_CONF (0x0F) bit masks +static constexpr uint8_t MEAS_TRIG = 0x01; // Bit 0: measurement trigger +static constexpr uint8_t MEAS_CONF_MASK = 0x06; // Bits 2:1: measurement mode +static constexpr uint8_t HRES_MASK = 0x30; // Bits 5:4: humidity resolution +static constexpr uint8_t TRES_MASK = 0xC0; // Bits 7:6: temperature resolution + +// REG_RESET_DRDY_INT_CONF (0x0E) bit masks +static constexpr uint8_t AMM_MASK = 0x70; // Bits 6:4: auto measurement mode void HDC2010Component::setup() { ESP_LOGCONFIG(TAG, "Running setup"); - const uint8_t data[2] = { - 0b00000000, // resolution 14bit for both humidity and temperature - 0b00000000 // reserved - }; - - if (!this->write_bytes(HDC2010_CMD_CONFIGURATION_MEASUREMENT, data, 2)) { - ESP_LOGW(TAG, "Initial config instruction error"); - this->status_set_warning(); - return; - } - - // Set measurement mode to temperature and humidity + // Set 14-bit resolution for both sensors and measurement mode to temp + humidity uint8_t config_contents; - this->read_register(MEASUREMENT_CONFIG, &config_contents, 1); - config_contents = (config_contents & 0xF9); // Always set to TEMP_AND_HUMID mode - this->write_bytes(MEASUREMENT_CONFIG, &config_contents, 1); + this->read_register(REG_MEASUREMENT_CONF, &config_contents, 1); + config_contents &= ~(TRES_MASK | HRES_MASK | MEAS_CONF_MASK); // 14-bit temp, 14-bit humidity, temp+humidity mode + this->write_bytes(REG_MEASUREMENT_CONF, &config_contents, 1); - // Set rate to manual - this->read_register(CONFIG, &config_contents, 1); - config_contents &= 0x8F; - this->write_bytes(CONFIG, &config_contents, 1); - - // Set temperature resolution to 14bit - this->read_register(CONFIG, &config_contents, 1); - config_contents &= 0x3F; - this->write_bytes(CONFIG, &config_contents, 1); - - // Set humidity resolution to 14bit - this->read_register(CONFIG, &config_contents, 1); - config_contents &= 0xCF; - this->write_bytes(CONFIG, &config_contents, 1); + // Set auto measurement rate to manual (on-demand via MEAS_TRIG) + this->read_register(REG_RESET_DRDY_INT_CONF, &config_contents, 1); + config_contents &= ~AMM_MASK; + this->write_bytes(REG_RESET_DRDY_INT_CONF, &config_contents, 1); } void HDC2010Component::dump_config() { @@ -67,9 +53,9 @@ void HDC2010Component::dump_config() { void HDC2010Component::update() { // Trigger measurement uint8_t config_contents; - this->read_register(CONFIG, &config_contents, 1); - config_contents |= 0x01; - this->write_bytes(MEASUREMENT_CONFIG, &config_contents, 1); + this->read_register(REG_MEASUREMENT_CONF, &config_contents, 1); + config_contents |= MEAS_TRIG; + this->write_bytes(REG_MEASUREMENT_CONF, &config_contents, 1); // 1ms delay after triggering the sample set_timeout(1, [this]() { @@ -90,8 +76,8 @@ void HDC2010Component::update() { float HDC2010Component::read_temp() { uint8_t byte[2]; - this->read_register(HDC2010_CMD_TEMPERATURE_LOW, &byte[0], 1); - this->read_register(HDC2010_CMD_TEMPERATURE_HIGH, &byte[1], 1); + this->read_register(REG_TEMPERATURE_LOW, &byte[0], 1); + this->read_register(REG_TEMPERATURE_HIGH, &byte[1], 1); uint16_t temp = encode_uint16(byte[1], byte[0]); return (float) temp * 0.0025177f - 40.0f; @@ -100,8 +86,8 @@ float HDC2010Component::read_temp() { float HDC2010Component::read_humidity() { uint8_t byte[2]; - this->read_register(HDC2010_CMD_HUMIDITY_LOW, &byte[0], 1); - this->read_register(HDC2010_CMD_HUMIDITY_HIGH, &byte[1], 1); + this->read_register(REG_HUMIDITY_LOW, &byte[0], 1); + this->read_register(REG_HUMIDITY_HIGH, &byte[1], 1); uint16_t humidity = encode_uint16(byte[1], byte[0]); return (float) humidity * 0.001525879f; diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 64d4dc41778..9f3c1484b0c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -406,7 +406,9 @@ QUANTILE_SCHEMA = cv.All( cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), - cv.Optional(CONF_QUANTILE, default=0.9): cv.zero_to_one_float, + cv.Optional(CONF_QUANTILE, default=0.9): cv.float_range( + min=0, min_included=False, max=1 + ), } ), validate_send_first_at, From 62f9bc79c4e4ee929e158cab64087c7bcd289072 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:48:21 -1000 Subject: [PATCH 195/279] [ci] Add CodSpeed badge to README (#14901) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b8ce8d091dc..16497ee0be2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ESPHome [![Discord Chat](https://img.shields.io/discord/429907082951524364.svg)](https://discord.gg/KhAMKrd) [![GitHub release](https://img.shields.io/github/release/esphome/esphome.svg)](https://GitHub.com/esphome/esphome/releases/) +# ESPHome [![Discord Chat](https://img.shields.io/discord/429907082951524364.svg)](https://discord.gg/KhAMKrd) [![GitHub release](https://img.shields.io/github/release/esphome/esphome.svg)](https://GitHub.com/esphome/esphome/releases/) [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/esphome/esphome) From 342020e1d3c53378febedc38819674b10049ea82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:49:24 -1000 Subject: [PATCH 196/279] [mqtt] Fix data race on inbound event queue (#14891) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/mqtt/mqtt_backend_esp32.cpp | 34 ++++++--- esphome/components/mqtt/mqtt_backend_esp32.h | 69 +++++++++++-------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 5642fd5f7b6..ab067c44182 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -82,10 +82,16 @@ bool MQTTBackendESP32::initialize_() { void MQTTBackendESP32::loop() { // process new events // handle only 1 message per loop iteration - if (!mqtt_events_.empty()) { - auto &event = mqtt_events_.front(); - mqtt_event_handler_(event); - mqtt_events_.pop(); + Event *event = this->mqtt_event_queue_.pop(); + if (event != nullptr) { + this->mqtt_event_handler_(*event); + this->mqtt_event_pool_.release(event); + } + + // Log dropped inbound events (check is cheap - single atomic load in common case) + uint16_t inbound_dropped = this->mqtt_event_queue_.get_and_reset_dropped_count(); + if (inbound_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u inbound MQTT events", inbound_dropped); } #if defined(USE_MQTT_IDF_ENQUEUE) @@ -183,10 +189,18 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { MQTTBackendESP32 *instance = static_cast(handler_args); - // queue event to decouple processing + // queue event to decouple processing from ESP-IDF MQTT task to main loop if (instance) { - auto event = *static_cast(event_data); - instance->mqtt_events_.emplace(event); + auto *event = instance->mqtt_event_pool_.allocate(); + if (event == nullptr) { + // Pool exhausted, drop event (counted via queue's dropped counter) + instance->mqtt_event_queue_.increment_dropped_count(); + return; + } + event->populate(*static_cast(event_data)); + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + instance->mqtt_event_queue_.push(event); // Wake main loop immediately to process MQTT event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) @@ -226,14 +240,14 @@ void MQTTBackendESP32::esphome_mqtt_task(void *params) { break; } } - this_mqtt->mqtt_event_pool_.release(elem); + this_mqtt->mqtt_outbound_pool_.release(elem); } } } bool MQTTBackendESP32::enqueue_(MqttQueueTypeT type, const char *topic, int qos, bool retain, const char *payload, size_t len) { - auto *elem = this->mqtt_event_pool_.allocate(); + auto *elem = this->mqtt_outbound_pool_.allocate(); if (!elem) { // Queue is full - increment counter but don't log immediately. @@ -253,7 +267,7 @@ bool MQTTBackendESP32::enqueue_(MqttQueueTypeT type, const char *topic, int qos, // Use the helper to allocate and copy data if (!elem->set_data(topic, payload, len)) { // Allocation failed, return elem to pool - this->mqtt_event_pool_.release(elem); + this->mqtt_outbound_pool_.release(elem); // Increment counter without logging to avoid cascade effect during memory pressure this->mqtt_queue_.increment_dropped_count(); return false; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index 5c4dc413bdc..58d1b29b325 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -5,7 +5,6 @@ #ifdef USE_ESP32 #include -#include #include #include #include @@ -18,32 +17,39 @@ namespace esphome::mqtt { struct Event { - esp_mqtt_event_id_t event_id; + esp_mqtt_event_id_t event_id{}; std::vector data; - int total_data_len; - int current_data_offset; + int total_data_len{0}; + int current_data_offset{0}; std::string topic; - int msg_id; - bool retain; - int qos; - bool dup; - bool session_present; - esp_mqtt_error_codes_t error_handle; + int msg_id{0}; + bool retain{false}; + int qos{0}; + bool dup{false}; + bool session_present{false}; + esp_mqtt_error_codes_t error_handle{}; - // Construct from esp_mqtt_event_t - // Any pointer values that are unsafe to keep are converted to safe copies - Event(const esp_mqtt_event_t &event) - : event_id(event.event_id), - data(event.data, event.data + event.data_len), - total_data_len(event.total_data_len), - current_data_offset(event.current_data_offset), - topic(event.topic, event.topic_len), - msg_id(event.msg_id), - retain(event.retain), - qos(event.qos), - dup(event.dup), - session_present(event.session_present), - error_handle(*event.error_handle) {} + // Populate from esp_mqtt_event_t + // Copies pointer-based data to owned storage for safe cross-thread transfer + void populate(const esp_mqtt_event_t &event) { + this->event_id = event.event_id; + this->data.assign(event.data, event.data + event.data_len); + this->total_data_len = event.total_data_len; + this->current_data_offset = event.current_data_offset; + this->topic.assign(event.topic, event.topic_len); + this->msg_id = event.msg_id; + this->retain = event.retain; + this->qos = event.qos; + this->dup = event.dup; + this->session_present = event.session_present; + this->error_handle = *event.error_handle; + } + + // Release owned resources for pool reuse (keeps allocated capacity for efficiency) + void release() { + this->data.clear(); + this->topic.clear(); + } }; enum MqttQueueTypeT : uint8_t { @@ -118,7 +124,8 @@ class MQTTBackendESP32 final : public MQTTBackend { static constexpr size_t TASK_STACK_SIZE = 3072; static constexpr size_t TASK_STACK_SIZE_TLS = 4096; // Larger stack for TLS operations static constexpr ssize_t TASK_PRIORITY = 5; - static constexpr uint8_t MQTT_QUEUE_LENGTH = 30; // 30*12 bytes = 360 + static constexpr uint8_t MQTT_QUEUE_LENGTH = 30; // 30*12 bytes = 360 + static constexpr uint8_t MQTT_EVENT_QUEUE_LENGTH = 32; // Inbound events from broker void set_keep_alive(uint16_t keep_alive) final { this->keep_alive_ = keep_alive; } void set_client_id(const char *client_id) final { this->client_id_ = client_id; } @@ -251,7 +258,8 @@ class MQTTBackendESP32 final : public MQTTBackend { bool skip_cert_cn_check_{false}; #if defined(USE_MQTT_IDF_ENQUEUE) static void esphome_mqtt_task(void *params); - EventPool mqtt_event_pool_; + // Pool sized to queue capacity (SIZE-1) — see mqtt_event_pool_ comment. + EventPool mqtt_outbound_pool_; NotifyingLockFreeQueue mqtt_queue_; TaskHandle_t task_handle_{nullptr}; bool enqueue_(MqttQueueTypeT type, const char *topic, int qos = 0, bool retain = false, const char *payload = NULL, @@ -266,7 +274,14 @@ class MQTTBackendESP32 final : public MQTTBackend { CallbackManager on_message_; CallbackManager on_publish_; std::string cached_topic_; - std::queue mqtt_events_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements (one slot distinguishes full from empty). + // This guarantees allocate() returns nullptr before push() can fail, which: + // 1. Prevents leaking a pool slot (the Nth allocate succeeds but push fails) + // 2. Avoids needing release() on the producer path after a failed push(), + // preserving the SPSC contract on the pool's internal free list + EventPool mqtt_event_pool_; + LockFreeQueue mqtt_event_queue_; #if defined(USE_MQTT_IDF_ENQUEUE) uint32_t last_dropped_log_time_{0}; From 0c5f055d45a333733460d5fe10877cefeb1a71ed Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 18 Mar 2026 01:16:01 +0100 Subject: [PATCH 197/279] [core] cpp tests: Allow customizing code generation during tests (#14681) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/core/__init__.py | 6 - esphome/loader.py | 15 +- script/{test_helpers.py => build_helpers.py} | 128 ++++++--- script/cpp_benchmark.py | 18 +- script/cpp_unit_test.py | 13 +- script/determine-jobs.py | 4 +- script/helpers.py | 5 +- tests/benchmarks/components/core/__init__.py | 7 + tests/benchmarks/components/host/__init__.py | 7 + tests/benchmarks/components/json/__init__.py | 7 + .../benchmarks/components/logger/__init__.py | 7 + tests/benchmarks/components/time/__init__.py | 9 + tests/components/README.md | 60 +++- tests/components/core/__init__.py | 8 + tests/components/host/__init__.py | 7 + tests/components/logger/__init__.py | 7 + tests/components/time/__init__.py | 9 + tests/script/test_determine_jobs.py | 2 +- tests/script/test_helpers.py | 22 -- tests/script/test_test_helpers.py | 260 ++++++++++++++++++ tests/testing_helpers.py | 63 +++++ tests/unit_tests/test_loader.py | 99 ++++++- 22 files changed, 667 insertions(+), 96 deletions(-) rename script/{test_helpers.py => build_helpers.py} (78%) create mode 100644 tests/benchmarks/components/core/__init__.py create mode 100644 tests/benchmarks/components/host/__init__.py create mode 100644 tests/benchmarks/components/json/__init__.py create mode 100644 tests/benchmarks/components/logger/__init__.py create mode 100644 tests/benchmarks/components/time/__init__.py create mode 100644 tests/components/core/__init__.py create mode 100644 tests/components/host/__init__.py create mode 100644 tests/components/logger/__init__.py create mode 100644 tests/components/time/__init__.py create mode 100644 tests/script/test_test_helpers.py create mode 100644 tests/testing_helpers.py diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index a86478aca19..009fef2f863 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,10 +615,6 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None - # True if compiling for C++ unit tests - self.cpp_testing = False - # Allowlist of components whose to_code should run during C++ testing - self.cpp_testing_codegen: set[str] = set() def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -648,8 +644,6 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None - self.cpp_testing = False - self.cpp_testing_codegen = set() PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/loader.py b/esphome/loader.py index 5771e074738..68664aaa265 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -71,11 +71,6 @@ class ComponentManifest: @property def to_code(self) -> Callable[[Any], None] | None: - if CORE.cpp_testing: - # During C++ testing, only run to_code for allowlisted components - name = self.module.__package__.rsplit(".", 1)[-1] - if name not in CORE.cpp_testing_codegen: - return None return getattr(self.module, "to_code", None) @property @@ -243,3 +238,13 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() _COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) + + +def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: + """Replace the cached manifest for a component. + + This is an intentionally-supported hook for the C++ test infrastructure + to install ``ComponentManifestOverride`` wrappers. Normal application + code should never call this. + """ + _COMPONENT_CACHE[domain] = manifest diff --git a/script/test_helpers.py b/script/build_helpers.py similarity index 78% rename from script/test_helpers.py rename to script/build_helpers.py index e872bbc5160..1cfae51fca9 100644 --- a/script/test_helpers.py +++ b/script/build_helpers.py @@ -2,21 +2,29 @@ from __future__ import annotations +from collections.abc import Callable import hashlib +import importlib.util import os from pathlib import Path import subprocess import sys -from helpers import get_all_dependencies +from helpers import get_all_dependencies, root_path as _root_path import yaml +# Ensure the repo root is on sys.path so that ``tests.testing_helpers`` and +# override ``__init__.py`` modules can ``from tests.testing_helpers import ...``. +if _root_path not in sys.path: + sys.path.insert(0, _root_path) + from esphome.__main__ import command_compile, parse_args from esphome.config import validate_config from esphome.const import CONF_PLATFORM from esphome.core import CORE -from esphome.loader import get_component +from esphome.loader import get_component, get_platform from esphome.platformio_api import get_idedata +from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest # This must coincide with the version in /platformio.ini PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" @@ -32,19 +40,6 @@ ESPHOME_KEY = "esphome" HOST_KEY = "host" LOGGER_KEY = "logger" -# Base config keys that are always present and must not be fully overridden -# by component benchmark.yaml files. esphome: allows sub-key merging. -BASE_CONFIG_KEYS = frozenset({ESPHOME_KEY, HOST_KEY, LOGGER_KEY}) - -# Shared build flag — enables timezone code paths for testing/benchmarking. -USE_TIME_TIMEZONE_FLAG = "-DUSE_TIME_TIMEZONE" - -# Components whose to_code should always run during C++ test/benchmark builds. -# These are the minimal infrastructure components needed for host compilation. -# Note: "core" is the esphome core config module (esphome/core/config.py), -# which registers under package name "core" not "esphome". -BASE_CODEGEN_COMPONENTS = {"core", "host", "logger"} - # Exit codes EXIT_OK = 0 EXIT_SKIPPED = 1 @@ -110,6 +105,8 @@ def get_platform_components(components: list[str], tests_dir: Path) -> list[str] if not domain_dir.is_dir(): continue domain = domain_dir.name + if domain.startswith("__"): + continue domain_module = get_component(domain) if domain_module is None or not domain_module.is_platform_component: raise ValueError( @@ -130,7 +127,8 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: The ``esphome:`` key is special: its sub-keys are merged into the existing esphome config (e.g. to add ``areas:`` or ``devices:``). - Other base config keys (``host:``, ``logger:``) are not overridable. + Keys already present in the base config (e.g. ``host:``, ``logger:``) + are protected by ``setdefault`` in the caller. Args: components: List of component directory names @@ -139,11 +137,6 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: Returns: Merged dict of component configs to add to the base config """ - # Note: components are processed in sorted order. For conflicting keys - # (e.g. two benchmark.yaml files both declaring sensor:), the first - # component alphabetically wins via setdefault(). This is fine for now - # with a single benchmark component (api) but would need a real merge - # strategy if multiple components declare overlapping configs. merged: dict = {} for component in components: yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME @@ -153,9 +146,6 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict: component_config = yaml.safe_load(f) if component_config and isinstance(component_config, dict): for key, value in component_config.items(): - if key in BASE_CONFIG_KEYS - {ESPHOME_KEY}: - # host: and logger: are not overridable - continue if key == ESPHOME_KEY and isinstance(value, dict): # Merge esphome sub-keys rather than replacing esphome_extra = merged.setdefault(ESPHOME_KEY, {}) @@ -198,11 +188,78 @@ def create_host_config( } +def _wrap_manifest( + comp_name: str, +) -> ComponentManifestOverride | None: + """Wrap a component manifest in a ComponentManifestOverride with to_code suppressed. + + If the manifest is already wrapped or not found, returns None. + Otherwise returns the newly created override after installing it. + """ + if "." in comp_name: + domain, component = comp_name.split(".", maxsplit=1) + manifest = get_platform(domain, component) + cache_key = f"{component}.{domain}" + else: + manifest = get_component(comp_name) + cache_key = comp_name + + if manifest is None or isinstance(manifest, ComponentManifestOverride): + return None + + override = ComponentManifestOverride(manifest) + override.to_code = None # suppress by default + set_testing_manifest(cache_key, override) + return override + + +def load_test_manifest_overrides( + components: list[str], + tests_dir: Path, +) -> None: + """Apply per-component manifest overrides from test ``__init__.py`` files. + + For every component, wraps its manifest and suppresses ``to_code``. + If the component's test directory contains an ``__init__.py`` that + defines ``override_manifest(manifest)``, it is called to customise + the override (e.g. ``manifest.enable_codegen()``). + """ + for comp_name in components: + override = _wrap_manifest(comp_name) + if override is None: + continue + + if "." in comp_name: + domain, component = comp_name.split(".", maxsplit=1) + cache_key = f"{component}.{domain}" + test_init = tests_dir / component / domain / "__init__.py" + else: + cache_key = comp_name + test_init = tests_dir / comp_name / "__init__.py" + + if not test_init.is_file(): + continue + spec = importlib.util.spec_from_file_location( + f"_test_manifest_override.{cache_key}", test_init + ) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + override_fn = getattr(mod, "override_manifest", None) + if override_fn is not None: + override_fn(override) + + +# Type alias for manifest override loaders +ManifestOverrideLoader = Callable[[list[str]], None] + + def compile_and_get_binary( config: dict, components: list[str], - codegen_components: set[str], tests_dir: Path, + manifest_override_loader: ManifestOverrideLoader, label: str = "build", ) -> tuple[int, str | None]: """Compile an ESPHome configuration and return the binary path. @@ -210,8 +267,8 @@ def compile_and_get_binary( Args: config: ESPHome configuration dict (already created via create_host_config) components: List of components to include in the build - codegen_components: Set of component names whose to_code should run tests_dir: Base directory for test files (used as config_path base) + manifest_override_loader: Callback to apply manifest overrides for components label: Label for log messages (e.g. "unit tests", "benchmarks") Returns: @@ -238,18 +295,21 @@ def compile_and_get_binary( else: config.setdefault(key, value) + # Apply manifest overrides before dependency resolution so that any + # dependency additions made by override_manifest() are picked up. + manifest_override_loader(components) + # Obtain possible dependencies BEFORE validate_config, because # get_all_dependencies calls CORE.reset() which clears build_path. - # Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag, - # which causes core/time.h to include components/time/posix_tz.h. components_with_dependencies: list[str] = sorted( - get_all_dependencies(set(components) | {"time"}, cpp_testing=True) + get_all_dependencies(set(components)) ) + # Apply overrides for any transitively discovered dependencies. + manifest_override_loader(components_with_dependencies) + CORE.config_path = tests_dir / "dummy.yaml" CORE.dashboard = None - CORE.cpp_testing = True - CORE.cpp_testing_codegen = codegen_components # Validate config will expand the above with defaults: config = validate_config(config, {}) @@ -305,7 +365,7 @@ def compile_and_get_binary( def build_and_run( selected_components: list[str], tests_dir: Path, - codegen_components: set[str], + manifest_override_loader: ManifestOverrideLoader, config_prefix: str, friendly_name: str, libraries: str | list[str], @@ -324,7 +384,7 @@ def build_and_run( Args: selected_components: Components to include (directory names in tests_dir) tests_dir: Directory containing test/benchmark files - codegen_components: Components whose to_code should run + manifest_override_loader: Callback to apply manifest overrides for components config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench") friendly_name: Human-readable name for the config libraries: PlatformIO library specification(s) @@ -382,7 +442,7 @@ def build_and_run( ) exit_code, program_path = compile_and_get_binary( - config, components, codegen_components, tests_dir, label + config, components, tests_dir, manifest_override_loader, label ) if exit_code != EXIT_OK or program_path is None: diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index bd92266ea62..a54d3752df6 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -2,18 +2,18 @@ """Build and run C++ benchmarks for ESPHome components using Google Benchmark.""" import argparse +from functools import partial import json import os from pathlib import Path import sys -from helpers import root_path -from test_helpers import ( - BASE_CODEGEN_COMPONENTS, +from build_helpers import ( PLATFORMIO_GOOGLE_BENCHMARK_LIB, - USE_TIME_TIMEZONE_FLAG, build_and_run, + load_test_manifest_overrides, ) +from helpers import root_path # Path to /tests/benchmarks/components BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" @@ -21,11 +21,6 @@ BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" # Path to /tests/benchmarks/core (always included, not a component) CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" -# Additional codegen components beyond the base set. -# json is needed because its to_code adds the ArduinoJson library -# (auto-loaded by api, but cpp_testing suppresses to_code unless listed). -BENCHMARK_CODEGEN_COMPONENTS = BASE_CODEGEN_COMPONENTS | {"json"} - PLATFORMIO_OPTIONS = { "build_unflags": [ "-Os", # remove default size-opt @@ -33,7 +28,6 @@ PLATFORMIO_OPTIONS = { "build_flags": [ "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) "-g", # debug symbols for profiling - USE_TIME_TIMEZONE_FLAG, "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() ], # Use deep+ LDF mode to ensure PlatformIO detects the benchmark @@ -73,7 +67,9 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> return build_and_run( selected_components=selected_components, tests_dir=BENCHMARKS_DIR, - codegen_components=BENCHMARK_CODEGEN_COMPONENTS, + manifest_override_loader=partial( + load_test_manifest_overrides, tests_dir=BENCHMARKS_DIR + ), config_prefix="cppbench", friendly_name="CPP Benchmarks", libraries=benchmark_lib, diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index 81c56b82da9..5594d642400 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 import argparse +from functools import partial from pathlib import Path import sys -from helpers import get_all_components, root_path -from test_helpers import ( - BASE_CODEGEN_COMPONENTS, +from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, - USE_TIME_TIMEZONE_FLAG, build_and_run, + load_test_manifest_overrides, ) +from helpers import get_all_components, root_path # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" @@ -21,7 +21,6 @@ PLATFORMIO_OPTIONS = { ], "build_flags": [ "-Og", # optimize for debug - USE_TIME_TIMEZONE_FLAG, "-DESPHOME_DEBUG", # enable debug assertions # Enable the address and undefined behavior sanitizers "-fsanitize=address", @@ -39,7 +38,9 @@ def run_tests(selected_components: list[str]) -> int: return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, - codegen_components=BASE_CODEGEN_COMPONENTS, + manifest_override_loader=partial( + load_test_manifest_overrides, tests_dir=COMPONENTS_TESTS_DIR + ), config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, diff --git a/script/determine-jobs.py b/script/determine-jobs.py index ad08f8dce56..9f322387801 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -388,7 +388,7 @@ BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" BENCHMARK_INFRASTRUCTURE_FILES = frozenset( { "script/cpp_benchmark.py", - "script/test_helpers.py", + "script/build_helpers.py", "script/setup_codspeed_lib.py", } ) @@ -402,7 +402,7 @@ def should_run_benchmarks(branch: str | None = None) -> bool: 1. Core C++ files changed (esphome/core/*) 2. A directly changed component has benchmark files (no dependency expansion) 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, - script/test_helpers.py, script/setup_codspeed_lib.py) + script/build_helpers.py, script/setup_codspeed_lib.py) Unlike unit tests, benchmarks do NOT expand to dependent components. Changing ``sensor`` does not trigger ``api`` benchmarks just because diff --git a/script/helpers.py b/script/helpers.py index 9665af70ec7..290dcadf0b9 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -627,14 +627,12 @@ def get_usable_cpu_count() -> int: def get_all_dependencies( - component_names: set[str], cpp_testing: bool = False + component_names: set[str], ) -> set[str]: """Get all dependencies for a set of components. Args: component_names: Set of component names to get dependencies for - cpp_testing: If True, set CORE.cpp_testing so AUTO_LOAD callables that - conditionally include testing-only dependencies work correctly Returns: Set of all components including dependencies and auto-loaded components @@ -652,7 +650,6 @@ def get_all_dependencies( # Reset CORE to ensure clean state CORE.reset() - CORE.cpp_testing = cpp_testing # Set up fake config path for component loading root = Path(__file__).parent.parent diff --git a/tests/benchmarks/components/core/__init__.py b/tests/benchmarks/components/core/__init__.py new file mode 100644 index 00000000000..d676ab669b6 --- /dev/null +++ b/tests/benchmarks/components/core/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # core (esphome/core/config.py) must run its to_code during builds + # because it bootstraps the fundamental application infrastructure. + manifest.enable_codegen() diff --git a/tests/benchmarks/components/host/__init__.py b/tests/benchmarks/components/host/__init__.py new file mode 100644 index 00000000000..f418c25f887 --- /dev/null +++ b/tests/benchmarks/components/host/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # host must run its to_code during builds because it sets up + # the host platform target execution environment. + manifest.enable_codegen() diff --git a/tests/benchmarks/components/json/__init__.py b/tests/benchmarks/components/json/__init__.py new file mode 100644 index 00000000000..56826b64bdf --- /dev/null +++ b/tests/benchmarks/components/json/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # json must run its to_code during benchmark builds because it + # adds the ArduinoJson library dependency needed by the API component. + manifest.enable_codegen() diff --git a/tests/benchmarks/components/logger/__init__.py b/tests/benchmarks/components/logger/__init__.py new file mode 100644 index 00000000000..cfab73e1e51 --- /dev/null +++ b/tests/benchmarks/components/logger/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # logger must run its to_code during builds because it configures + # the logging subsystem used by ESP_LOG* macros. + manifest.enable_codegen() diff --git a/tests/benchmarks/components/time/__init__.py b/tests/benchmarks/components/time/__init__.py new file mode 100644 index 00000000000..7f68003e29d --- /dev/null +++ b/tests/benchmarks/components/time/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code(config): + cg.add_build_flag("-DUSE_TIME_TIMEZONE") + + manifest.to_code = to_code diff --git a/tests/components/README.md b/tests/components/README.md index 6da0dadd25e..145a3440d29 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -14,11 +14,63 @@ include the relevant `.cpp` and `.h` test files there. ### Override component code generation for testing -When generating code for testing, ESPHome won't invoke the component's `to_code` function, since most components do not -need to generate configuration code for testing. +During C++ test builds, `to_code` is suppressed for every component by default — most components do not +need to generate configuration code for a unit test binary. -If you do need to generate code to for example configure compilation flags or add libraries, -add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`. +#### Manifest overrides + +If your component needs to customise code generation behavior for testing — for example to re-enable +`to_code`, supply a lightweight stub, add a test-only dependency, or change any other manifest attribute — +create an `__init__.py` in your component's test directory and define `override_manifest`: + +**Top-level component** (`tests/components//__init__.py`): + +```python +from tests.testing_helpers import ComponentManifestOverride + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Re-enable the component's own to_code (needed when the component must + # emit C++ setup code that the test binary depends on at link time). + manifest.enable_codegen() +``` + +Or supply a lightweight stub instead of the real `to_code`: + +```python +from tests.testing_helpers import ComponentManifestOverride + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config): + # Only emit what the C++ tests actually need + pass + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["some_test_only_dep"] +``` + +**Platform component** (`tests/components///__init__.py`, +e.g. `tests/components/my_sensor/sensor/__init__.py`): + +```python +from tests.testing_helpers import ComponentManifestOverride + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() +``` + +`override_manifest` receives a `ComponentManifestOverride` that wraps the real manifest. +Attribute assignments store an override; reads fall back to the real manifest when no +override is present. + +Key methods: + +| Method | Effect | +|---|---| +| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation | +| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original | + +The function is called after `to_code` has already been set to `None`, so calling +`enable_codegen()` is a deliberate opt-in. ## Running component unit tests diff --git a/tests/components/core/__init__.py b/tests/components/core/__init__.py new file mode 100644 index 00000000000..34ca4fbe4f0 --- /dev/null +++ b/tests/components/core/__init__.py @@ -0,0 +1,8 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # core (esphome/core/config.py) must run its to_code during C++ test builds + # because it bootstraps the fundamental application infrastructure that all + # components depend on (component registration, event loop, etc.). + manifest.enable_codegen() diff --git a/tests/components/host/__init__.py b/tests/components/host/__init__.py new file mode 100644 index 00000000000..bcb363cdc39 --- /dev/null +++ b/tests/components/host/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # host must run its to_code during C++ test builds because it sets up the + # host platform target, which is the execution environment for all unit tests. + manifest.enable_codegen() diff --git a/tests/components/logger/__init__.py b/tests/components/logger/__init__.py new file mode 100644 index 00000000000..3acfe02748c --- /dev/null +++ b/tests/components/logger/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # logger must run its to_code during C++ test builds because it configures + # the logging subsystem used by ESP_LOG* macros throughout component code. + manifest.enable_codegen() diff --git a/tests/components/time/__init__.py b/tests/components/time/__init__.py new file mode 100644 index 00000000000..7f68003e29d --- /dev/null +++ b/tests/components/time/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code(config): + cg.add_build_flag("-DUSE_TIME_TIMEZONE") + + manifest.to_code = to_code diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 29535d1fd38..de239ee0b55 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1846,7 +1846,7 @@ def test_should_run_benchmarks_benchmark_infra_change() -> None: """Test benchmarks trigger on benchmark infrastructure changes.""" for infra_file in [ "script/cpp_benchmark.py", - "script/test_helpers.py", + "script/build_helpers.py", "script/setup_codspeed_lib.py", ]: with patch.object(determine_jobs, "changed_files", return_value=[infra_file]): diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index e3802d2d51f..28f111d7587 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1073,28 +1073,6 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None: assert result == {"sensor.bthome", "sensor"} -def test_get_all_dependencies_cpp_testing_flag() -> None: - """cpp_testing=True propagates to CORE.cpp_testing during resolution.""" - from esphome.core import CORE - - with ( - patch("esphome.loader.get_component") as mock_get_component, - patch("esphome.loader.get_platform"), - ): - observed: list[bool] = [] - - def capturing_get_component(name: str): - observed.append(CORE.cpp_testing) - - mock_get_component.side_effect = capturing_get_component - - helpers.get_all_dependencies({"some_comp"}, cpp_testing=True) - - assert observed and all(observed), ( - "CORE.cpp_testing should be True during resolution" - ) - - def test_get_components_from_integration_fixtures() -> None: """Test extraction of components from fixture YAML files.""" yaml_content = { diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py new file mode 100644 index 00000000000..467940fc33d --- /dev/null +++ b/tests/script/test_test_helpers.py @@ -0,0 +1,260 @@ +"""Unit tests for script/build_helpers.py manifest override and build helpers.""" + +import os +from pathlib import Path +import sys +import textwrap +from unittest.mock import MagicMock, patch + +import pytest + +# Add the script directory to Python path so we can import build_helpers +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) +) + +import build_helpers # noqa: E402 + +from esphome.loader import ComponentManifest # noqa: E402 +from tests.testing_helpers import ComponentManifestOverride # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_component_manifest(*, to_code=None, dependencies=None) -> ComponentManifest: + mod = MagicMock() + mod.to_code = to_code + mod.DEPENDENCIES = dependencies or [] + return ComponentManifest(mod) + + +# --------------------------------------------------------------------------- +# filter_components_with_files +# --------------------------------------------------------------------------- + + +def test_filter_keeps_components_with_cpp_files(tmp_path: Path) -> None: + comp_dir = tmp_path / "mycomp" + comp_dir.mkdir() + (comp_dir / "mycomp_test.cpp").write_text("") + + result = build_helpers.filter_components_with_files(["mycomp"], tmp_path) + + assert result == ["mycomp"] + + +def test_filter_keeps_components_with_h_files(tmp_path: Path) -> None: + comp_dir = tmp_path / "mycomp" + comp_dir.mkdir() + (comp_dir / "helpers.h").write_text("") + + result = build_helpers.filter_components_with_files(["mycomp"], tmp_path) + + assert result == ["mycomp"] + + +def test_filter_drops_components_without_test_dir(tmp_path: Path) -> None: + result = build_helpers.filter_components_with_files(["nodir"], tmp_path) + + assert result == [] + + +def test_filter_drops_components_with_no_cpp_or_h(tmp_path: Path) -> None: + comp_dir = tmp_path / "mycomp" + comp_dir.mkdir() + (comp_dir / "README.md").write_text("") + + result = build_helpers.filter_components_with_files(["mycomp"], tmp_path) + + assert result == [] + + +# --------------------------------------------------------------------------- +# get_platform_components +# --------------------------------------------------------------------------- + + +def test_get_platform_components_discovers_subdirectory(tmp_path: Path) -> None: + (tmp_path / "bthome" / "sensor").mkdir(parents=True) + + sensor_mod = MagicMock() + sensor_mod.IS_PLATFORM_COMPONENT = True + + with patch( + "build_helpers.get_component", return_value=ComponentManifest(sensor_mod) + ): + result = build_helpers.get_platform_components(["bthome"], tmp_path) + + assert result == ["sensor.bthome"] + + +def test_get_platform_components_skips_pycache(tmp_path: Path) -> None: + (tmp_path / "bthome" / "__pycache__").mkdir(parents=True) + + result = build_helpers.get_platform_components(["bthome"], tmp_path) + + assert result == [] + + +def test_get_platform_components_raises_for_invalid_domain(tmp_path: Path) -> None: + (tmp_path / "bthome" / "notadomain").mkdir(parents=True) + + with ( + patch("build_helpers.get_component", return_value=None), + pytest.raises(ValueError, match="notadomain"), + ): + build_helpers.get_platform_components(["bthome"], tmp_path) + + +# --------------------------------------------------------------------------- +# load_test_manifest_overrides +# --------------------------------------------------------------------------- + + +def test_load_suppresses_to_code(tmp_path: Path) -> None: + """to_code is always set to None before the override is called.""" + + async def real_to_code(config): + pass + + inner = _make_component_manifest(to_code=real_to_code) + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path) + installed: ComponentManifestOverride = mock_set.call_args[0][1] + + assert installed.to_code is None + + +def test_load_calls_override_fn(tmp_path: Path) -> None: + """override_manifest() in test_init is called with the ComponentManifestOverride.""" + comp_dir = tmp_path / "mycomp" + comp_dir.mkdir() + init_py = comp_dir / "__init__.py" + init_py.write_text( + textwrap.dedent("""\ + def override_manifest(manifest): + manifest.dependencies = ["injected"] + """) + ) + + inner = _make_component_manifest() + override = ComponentManifestOverride(inner) + override.to_code = None + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path) + installed: ComponentManifestOverride = mock_set.call_args[0][1] + + assert installed.dependencies == ["injected"] + + +def test_load_enable_codegen_in_override(tmp_path: Path) -> None: + """An override_manifest that calls enable_codegen() restores to_code.""" + + async def real_to_code(config): + pass + + comp_dir = tmp_path / "mycomp" + comp_dir.mkdir() + init_py = comp_dir / "__init__.py" + init_py.write_text( + textwrap.dedent("""\ + def override_manifest(manifest): + manifest.enable_codegen() + """) + ) + + inner = _make_component_manifest(to_code=real_to_code) + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path) + installed: ComponentManifestOverride = mock_set.call_args[0][1] + + assert installed.to_code is real_to_code + + +def test_load_no_override_file(tmp_path: Path) -> None: + """No override file: manifest is wrapped and to_code suppressed, nothing else.""" + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path) + + mock_set.assert_called_once() + key, installed = mock_set.call_args[0] + assert key == "mycomp" + assert isinstance(installed, ComponentManifestOverride) + + +def test_load_skips_already_wrapped(tmp_path: Path) -> None: + """Components already wrapped as ComponentManifestOverride are not double-wrapped.""" + inner = _make_component_manifest() + already_wrapped = ComponentManifestOverride(inner) + + with ( + patch("build_helpers.get_component", return_value=already_wrapped), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path) + + mock_set.assert_not_called() + + +def test_load_skips_platform_component_already_wrapped(tmp_path: Path) -> None: + inner = _make_component_manifest() + already_wrapped = ComponentManifestOverride(inner) + + with ( + patch("build_helpers.get_platform", return_value=already_wrapped), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path) + + mock_set.assert_not_called() + + +def test_load_wraps_top_level_component(tmp_path: Path) -> None: + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path) + + mock_set.assert_called_once() + key, installed = mock_set.call_args[0] + assert key == "mycomp" + assert isinstance(installed, ComponentManifestOverride) + assert installed.to_code is None + + +def test_load_wraps_platform_component(tmp_path: Path) -> None: + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_platform", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path) + + mock_set.assert_called_once() + key, installed = mock_set.call_args[0] + assert key == "bthome.sensor" + assert isinstance(installed, ComponentManifestOverride) + assert installed.to_code is None diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py new file mode 100644 index 00000000000..20b76697a19 --- /dev/null +++ b/tests/testing_helpers.py @@ -0,0 +1,63 @@ +from typing import Any + +from esphome.loader import ComponentManifest, _replace_component_manifest + + +class ComponentManifestOverride: + """Mutable wrapper around ComponentManifest for test-specific attribute overrides. + + When ``tests/components//__init__.py`` defines:: + + def override_manifest(manifest: ComponentManifestOverride) -> None: + ... + + the function receives an instance of this class wrapping the real component + manifest. Any attribute assignment stores an override; reads fall back to + the underlying ``ComponentManifest`` when no override has been set. + + Example:: + + def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config): + pass # lightweight no-op stub for C++ unit tests + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["extra_dep_for_tests"] + """ + + def __init__(self, wrapped: "ComponentManifest") -> None: + object.__setattr__(self, "_wrapped", wrapped) + object.__setattr__(self, "_overrides", {}) + + def __getattr__(self, name: str) -> Any: + overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") + if name in overrides: + return overrides[name] + wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped") + return getattr(wrapped, name) + + def __setattr__(self, name: str, value: Any) -> None: + overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") + overrides[name] = value + + def enable_codegen(self) -> None: + """Remove the to_code suppression, re-enabling code generation for this component. + + Call this from ``override_manifest`` when the component needs its real (or a + custom stub) ``to_code`` to run during C++ unit test builds. + """ + overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") + overrides.pop("to_code", None) + + def restore(self) -> None: + """Clear all overrides, reverting to the wrapped manifest's values.""" + object.__getattribute__(self, "_overrides").clear() + + +def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None: + """Install a testing manifest override into the component cache. + + Called from the C++ unit test infrastructure when a component's test + directory provides an ``override_manifest`` function. + """ + _replace_component_manifest(domain, manifest) diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index c6d4c4aef04..a42cc5cca73 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -2,7 +2,104 @@ from unittest.mock import MagicMock, patch -from esphome.loader import ComponentManifest +from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +from tests.testing_helpers import ComponentManifestOverride + +# --------------------------------------------------------------------------- +# ComponentManifestOverride +# --------------------------------------------------------------------------- + + +def _make_manifest(*, to_code=None, dependencies=None) -> ComponentManifest: + """Return a ComponentManifest backed by a minimal mock module.""" + mod = MagicMock() + mod.to_code = to_code + mod.DEPENDENCIES = dependencies or [] + return ComponentManifest(mod) + + +def test_testing_manifest_delegates_to_wrapped() -> None: + """Unoverridden attributes fall through to the wrapped manifest.""" + inner = _make_manifest(dependencies=["wifi"]) + tm = ComponentManifestOverride(inner) + assert tm.dependencies == ["wifi"] + + +def test_testing_manifest_override_shadows_wrapped() -> None: + """An assigned attribute shadows the wrapped value.""" + inner = _make_manifest(dependencies=["wifi"]) + tm = ComponentManifestOverride(inner) + tm.dependencies = ["ble"] + assert tm.dependencies == ["ble"] + # Wrapped value unchanged + assert inner.dependencies == ["wifi"] + + +def test_testing_manifest_to_code_suppression() -> None: + """Setting to_code=None suppresses code generation.""" + + async def real_to_code(config): + pass + + inner = _make_manifest(to_code=real_to_code) + tm = ComponentManifestOverride(inner) + tm.to_code = None + assert tm.to_code is None + + +def test_testing_manifest_enable_codegen_removes_suppression() -> None: + """enable_codegen() removes the to_code override, restoring the original.""" + + async def real_to_code(config): + pass + + inner = _make_manifest(to_code=real_to_code) + tm = ComponentManifestOverride(inner) + tm.to_code = None + assert tm.to_code is None + + tm.enable_codegen() + assert tm.to_code is real_to_code + + +def test_testing_manifest_enable_codegen_preserves_other_overrides() -> None: + """enable_codegen() only removes to_code; other overrides survive.""" + inner = _make_manifest(dependencies=["wifi"]) + tm = ComponentManifestOverride(inner) + tm.to_code = None + tm.dependencies = ["ble"] + + tm.enable_codegen() + + assert tm.to_code is inner.to_code + assert tm.dependencies == ["ble"] + + +def test_testing_manifest_restore_clears_all_overrides() -> None: + """restore() removes every override, reverting all attributes to wrapped values.""" + + async def real_to_code(config): + pass + + inner = _make_manifest(to_code=real_to_code, dependencies=["wifi"]) + tm = ComponentManifestOverride(inner) + tm.to_code = None + tm.dependencies = ["ble"] + + tm.restore() + + assert tm.to_code is real_to_code + assert tm.dependencies == ["wifi"] + + +def test_replace_component_manifest_installs_override() -> None: + """_replace_component_manifest replaces the cached manifest for a domain.""" + inner = _make_manifest() + override = ComponentManifestOverride(inner) + + _replace_component_manifest("_test_dummy_domain", override) + + assert get_component("_test_dummy_domain") is override def test_component_manifest_resources_with_filter_source_files() -> None: From b9e8da92c734a4b761b9835b6e584d87c1d47eaa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 14:19:31 -1000 Subject: [PATCH 198/279] [scheduler] Fix UB in cross-thread counter/vector reads, add atomic fast-path (#14880) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/core/scheduler.cpp | 48 ++++++----- esphome/core/scheduler.h | 159 ++++++++++++++++++++++++++++++++++--- 2 files changed, 177 insertions(+), 30 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 72b183384e3..db40ede78c6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -212,6 +212,14 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } target->push_back(item); + if (target == &this->to_add_) { + this->to_add_count_increment_(); + } +#ifndef ESPHOME_THREAD_SINGLE + else { + this->defer_count_increment_(); + } +#endif } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, @@ -388,7 +396,7 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { // safe when called from the main thread. Other threads must not call this method. // If no items, return empty optional - if (this->cleanup_() == 0) + if (!this->cleanup_()) return {}; SchedulerItem *item = this->items_[0]; @@ -422,7 +430,7 @@ void Scheduler::full_cleanup_removed_items_() { this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_ = 0; + this->to_remove_clear_(); } #ifndef ESPHOME_THREAD_SINGLE @@ -503,7 +511,7 @@ void HOT Scheduler::call(uint32_t now) { // If we still have too many cancelled items, do a full cleanup // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { this->full_cleanup_removed_items_(); } while (!this->items_.empty()) { @@ -530,7 +538,7 @@ void HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_--; + this->to_remove_decrement_(); continue; } } @@ -539,7 +547,7 @@ void HOT Scheduler::call(uint32_t now) { if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_--; + this->to_remove_decrement_(); continue; } #endif @@ -567,7 +575,7 @@ void HOT Scheduler::call(uint32_t now) { if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue - this->to_remove_--; + this->to_remove_decrement_(); this->recycle_item_main_loop_(executed_item); continue; } @@ -577,6 +585,7 @@ void HOT Scheduler::call(uint32_t now) { // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(executed_item); + this->to_add_count_increment_(); } else { // Timeout completed - recycle it this->recycle_item_main_loop_(executed_item); @@ -605,6 +614,10 @@ void HOT Scheduler::call(uint32_t now) { #endif } void HOT Scheduler::process_to_add() { + // Fast path: skip lock acquisition when nothing to add. + // Worst case is a one-loop-iteration delay before newly added items are processed. + if (this->to_add_empty_()) + return; LockGuard guard{this->lock_}; for (auto *&it : this->to_add_) { if (is_item_removed_locked_(it)) { @@ -618,17 +631,14 @@ void HOT Scheduler::process_to_add() { std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); + this->to_add_count_clear_(); } -size_t HOT Scheduler::cleanup_() { - // Fast path: if nothing to remove, just return the current size - // Reading to_remove_ without lock is safe because: - // 1. We only call this from the main thread during call() - // 2. If it's 0, there's definitely nothing to cleanup - // 3. If it becomes non-zero after we check, cleanup will happen on the next loop iteration - // 4. Not all platforms support atomics, so we accept this race in favor of performance - // 5. The worst case is a one-loop-iteration delay in cleanup, which is harmless - if (this->to_remove_ == 0) - return this->items_.size(); +bool HOT Scheduler::cleanup_() { + // Fast path: if nothing to remove, just check if items exist. + // Uses atomic load on platforms with atomics, falls back to always taking the lock otherwise. + // Worst case is a one-loop-iteration delay in cleanup. + if (this->to_remove_empty_()) + return !this->items_.empty(); // We must hold the lock for the entire cleanup operation because: // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access @@ -643,10 +653,10 @@ size_t HOT Scheduler::cleanup_() { SchedulerItem *item = this->items_[0]; if (!this->is_item_removed_locked_(item)) break; - this->to_remove_--; + this->to_remove_decrement_(); this->recycle_item_main_loop_(this->pop_raw_locked_()); } - return this->items_.size(); + return !this->items_.empty(); } Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); @@ -699,7 +709,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry); total_cancelled += heap_cancelled; - this->to_remove_ += heap_cancelled; + this->to_remove_add_(heap_cancelled); } // Cancel items in to_add_ diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 0476513bb99..e545055fcae 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -284,9 +284,9 @@ class Scheduler { #endif } // Cleanup logically deleted items from the scheduler - // Returns the number of items remaining after cleanup + // Returns true if items remain after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). - size_t cleanup_(); + bool cleanup_(); // Remove and return the front item from the heap as a raw pointer. // Caller takes ownership and must either recycle or delete the item. // IMPORTANT: Caller must hold the scheduler lock before calling this function. @@ -395,15 +395,9 @@ class Scheduler { // erase() on every pop, which would be O(n). The queue is processed once per loop - // any items added during processing are left for the next loop iteration. - // Snapshot the queue end point - only process items that existed at loop start - // Items added during processing (by callbacks or other threads) run next loop - // No lock needed: single consumer (main loop), stale read just means we process less this iteration - size_t defer_queue_end = this->defer_queue_.size(); - // Fast path: nothing to process, avoid lock entirely. - // Safe without lock: single consumer (main loop) reads front_, and a stale size() read - // from a concurrent push can only make us see fewer items — they'll be processed next loop. - if (this->defer_queue_front_ >= defer_queue_end) + // Worst case is a one-loop-iteration delay before newly deferred items are processed. + if (this->defer_empty_()) return; // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), @@ -412,6 +406,13 @@ class Scheduler { SchedulerItem *item; this->lock_.lock(); + // Reset counter and snapshot queue end under lock + this->defer_count_clear_(); + size_t defer_queue_end = this->defer_queue_.size(); + if (this->defer_queue_front_ >= defer_queue_end) { + this->lock_.unlock(); + return; + } while (this->defer_queue_front_ < defer_queue_end) { // Take ownership of the item, leaving nullptr in the vector slot. // This is safe because: @@ -527,14 +528,150 @@ class Scheduler { Mutex lock_; std::vector items_; std::vector to_add_; + +#ifndef ESPHOME_THREAD_SINGLE + // Fast-path counter for process_to_add() to skip taking the lock when there is + // nothing to add. Uses std::atomic on platforms that support it, plain uint32_t + // otherwise. On non-atomic platforms, callers must hold the scheduler lock when + // mutating this counter. Not needed on single-threaded platforms where we can + // check to_add_.empty() directly. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + std::atomic to_add_count_{0}; +#else + uint32_t to_add_count_{0}; +#endif +#endif /* ESPHOME_THREAD_SINGLE */ + + // Fast-path helper for process_to_add() to decide if it can try the lock-free path. + // - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers). + // - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_. + // - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller + // down the locked path; this is NOT a lock-free emptiness check on that platform. + bool to_add_empty_() const { +#ifdef ESPHOME_THREAD_SINGLE + return this->to_add_.empty(); +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) + return this->to_add_count_.load(std::memory_order_relaxed) == 0; +#else + return false; +#endif + } + + // Increment to_add_count_ (no-op on single-threaded platforms) + void to_add_count_increment_() { +#ifdef ESPHOME_THREAD_SINGLE + // No counter needed — to_add_empty_() checks the vector directly +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) + this->to_add_count_.fetch_add(1, std::memory_order_relaxed); +#else + this->to_add_count_++; +#endif + } + + // Reset to_add_count_ (no-op on single-threaded platforms) + void to_add_count_clear_() { +#ifdef ESPHOME_THREAD_SINGLE + // No counter needed — to_add_empty_() checks the vector directly +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) + this->to_add_count_.store(0, std::memory_order_relaxed); +#else + this->to_add_count_ = 0; +#endif + } + #ifndef ESPHOME_THREAD_SINGLE // Single-core platforms don't need the defer queue and save ~32 bytes of RAM // Using std::vector instead of std::deque avoids 512-byte chunked allocations // Index tracking avoids O(n) erase() calls when draining the queue each loop std::vector defer_queue_; // FIFO queue for defer() calls size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) -#endif /* ESPHOME_THREAD_SINGLE */ + + // Fast-path counter for process_defer_queue_() to skip lock when nothing to process. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + std::atomic defer_count_{0}; +#else + uint32_t defer_count_{0}; +#endif + + bool defer_empty_() const { + // defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path + // ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return this->defer_count_.load(std::memory_order_relaxed) == 0; +#else + return false; +#endif + } + + void defer_count_increment_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->defer_count_.fetch_add(1, std::memory_order_relaxed); +#else + this->defer_count_++; +#endif + } + + void defer_count_clear_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->defer_count_.store(0, std::memory_order_relaxed); +#else + this->defer_count_ = 0; +#endif + } + +#endif /* ESPHOME_THREAD_SINGLE */ + + // Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_(). + // On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path; + // on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + std::atomic to_remove_{0}; +#else uint32_t to_remove_{0}; +#endif + + // Lock-free check if there are items to remove (for fast-path in cleanup_) + bool to_remove_empty_() const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return this->to_remove_.load(std::memory_order_relaxed) == 0; +#elif defined(ESPHOME_THREAD_SINGLE) + return this->to_remove_ == 0; +#else + return false; // Always take the lock path +#endif + } + + void to_remove_add_(uint32_t count) { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#else + this->to_remove_ += count; +#endif + } + + void to_remove_decrement_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#else + this->to_remove_--; +#endif + } + + void to_remove_clear_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->to_remove_.store(0, std::memory_order_relaxed); +#else + this->to_remove_ = 0; +#endif + } + + uint32_t to_remove_count_() const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return this->to_remove_.load(std::memory_order_relaxed); +#else + return this->to_remove_; +#endif + } // Memory pool for recycling SchedulerItem objects to reduce heap churn. // Design decisions: From 8bbfadb59aa39b02156653dee670f6c2990cd506 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Tue, 17 Mar 2026 14:22:31 +0100 Subject: [PATCH 199/279] [core] Small improvements (#14884) --- esphome/components/bme68x_bsec2/__init__.py | 4 ++-- script/merge_component_configs.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 4200b2f0b85..5f0afa9c9f2 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -186,8 +186,8 @@ async def to_code_base(config): cg.add_library("SPI", None) cg.add_library( "BME68x Sensor library", - "1.3.40408", - "https://github.com/boschsensortec/Bosch-BME68x-Library", + None, + "https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408", ) cg.add_library( "BSEC2 Software Library", diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5e98f1fef5a..41bbafcd021 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -384,7 +384,7 @@ def merge_component_configs( # Write merged config output_file.parent.mkdir(parents=True, exist_ok=True) yaml_content = yaml_util.dump(merged_config_data) - output_file.write_text(yaml_content) + output_file.write_text(yaml_content, encoding="utf-8") print(f"Successfully merged {len(component_names)} components into {output_file}") From 37f9541f322d3ccee1c232df8b2a9a503165d41a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 08:14:36 -1000 Subject: [PATCH 200/279] [api] Fix ProtoMessage protected destructor compile error on host platform (#14882) --- esphome/components/api/proto.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index d1c955b1fb9..44d8f04585d 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -442,8 +442,12 @@ class ProtoMessage { virtual const char *message_name() const { return "unknown"; } #endif +#ifndef USE_HOST protected: +#endif // Non-virtual destructor is protected to prevent polymorphic deletion. + // On host platform, made public to allow value-initialization of std::array + // members (e.g. DeviceInfoResponse::devices) without clang errors. ~ProtoMessage() = default; }; From c5d42b05696f0238929447fcbb3db6ba1018ae82 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:18:49 -0400 Subject: [PATCH 201/279] [speaker] Fix media playlist using announcement delay (#14889) --- .../components/speaker/media_player/speaker_media_player.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 9f168f854d8..930373c6fcd 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -417,7 +417,7 @@ void SpeakerMediaPlayer::loop() { this->media_playlist_.pop_front(); } // Only delay starting playback if moving on the next playlist item or repeating the current item - timeout_ms = this->announcement_playlist_delay_ms_; + timeout_ms = this->media_playlist_delay_ms_; } if (!this->media_playlist_.empty()) { PlaylistItem playlist_item = this->media_playlist_.front(); From 4122fa5dddaea6b354592dd41e20ad6179223b76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 09:58:05 -1000 Subject: [PATCH 202/279] [core] Add back deprecated set_internal() for external projects (#14887) --- esphome/core/entity_base.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index cccbafd2c36..723bf54584d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -100,6 +100,14 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } + // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients + // are NOT notified of the change, the flag may have already been read during setup, and there + // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. + ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " + "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", + "2026.3.0") + void set_internal(bool internal) { this->flags_.internal = internal; } + // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should // not be added to the default view by default, and a user action is necessary to manually add it. From 1b70df2c1f8a9a7edbc3b389fe6095ddd7e9f2b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:16:44 -1000 Subject: [PATCH 203/279] [espnow] Fix EventPool/LockFreeQueue sizing off-by-one (#14893) --- esphome/components/espnow/espnow_component.cpp | 6 ++++-- esphome/components/espnow/espnow_component.h | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 991803d8703..78916891f4d 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -87,7 +87,8 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) // Push the packet to the queue global_esp_now->receive_packet_queue_.push(packet); - // Push always because we're the only producer and the pool ensures we never exceed queue size + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. // Wake main loop immediately to process ESP-NOW send event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) @@ -109,7 +110,8 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // Push the packet to the queue global_esp_now->receive_packet_queue_.push(packet); - // Push always because we're the only producer and the pool ensures we never exceed queue size + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. // Wake main loop immediately to process ESP-NOW receive event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 9941e972270..ee4adc1b4d4 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -163,10 +163,14 @@ class ESPNowComponent : public Component { uint8_t own_address_[ESP_NOW_ETH_ALEN]{0}; LockFreeQueue receive_packet_queue_{}; - EventPool receive_packet_pool_{}; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing a pool slot leak. + EventPool receive_packet_pool_{}; LockFreeQueue send_packet_queue_{}; - EventPool send_packet_pool_{}; + // Pool sized to queue capacity (SIZE-1) — see receive_packet_pool_ comment. + EventPool send_packet_pool_{}; ESPNowSendPacket *current_send_packet_{nullptr}; // Currently sending packet, nullptr if none uint8_t wifi_channel_{0}; From 8caa11dcf45aed498be5ec8ef83611be86b9a547 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:17:43 -1000 Subject: [PATCH 204/279] [usb_cdc_acm] Fix EventPool/LockFreeQueue sizing off-by-one (#14894) --- .../components/usb_cdc_acm/usb_cdc_acm.cpp | 26 +++++++------------ esphome/components/usb_cdc_acm/usb_cdc_acm.h | 6 ++++- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index a4c2e6c4a48..253626f0a3e 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -26,16 +26,13 @@ void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { event->data.line_state.dtr = dtr; event->data.line_state.rts = rts; - if (!this->event_queue_.push(event)) { - ESP_LOGW(TAG, "Event queue full, line state event dropped (itf=%d)", this->itf_); - // Return event to pool since we couldn't queue it - this->event_pool_.release(event); - } else { - // Wake main loop immediately to process event + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + this->event_queue_.push(event); + #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - App.wake_loop_threadsafe(); + App.wake_loop_threadsafe(); #endif - } } void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, @@ -53,16 +50,13 @@ void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_ event->data.line_coding.parity = parity; event->data.line_coding.data_bits = data_bits; - if (!this->event_queue_.push(event)) { - ESP_LOGW(TAG, "Event queue full, line coding event dropped (itf=%d)", this->itf_); - // Return event to pool since we couldn't queue it - this->event_pool_.release(event); - } else { - // Wake main loop immediately to process event + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + this->event_queue_.push(event); + #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - App.wake_loop_threadsafe(); + App.wake_loop_threadsafe(); #endif - } } void USBCDCACMInstance::process_events_() { diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 624f41cf8c6..90c673a89e2 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -102,7 +102,11 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented event_pool_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing both a pool slot leak and an SPSC + // violation on the pool's internal free list. + EventPool event_pool_; LockFreeQueue event_queue_; }; From 3bde7ec978f2d691586700ab107cff59a0f014c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:17:59 -1000 Subject: [PATCH 205/279] [usb_host] Fix EventPool/LockFreeQueue sizing off-by-one (#14896) --- esphome/components/usb_host/usb_host.h | 5 ++++- esphome/components/usb_host/usb_host_client.cpp | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 2eec0c9699b..dcb76a3a3ba 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -144,7 +144,10 @@ class USBClient : public Component { // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks LockFreeQueue event_queue; - EventPool event_pool; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing a pool slot leak. + EventPool event_pool; protected: // Process USB events from the queue. Returns true if any work was done. diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 2a460d1a077..18d938344c7 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -193,7 +193,8 @@ static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void * return; } - // Push to lock-free queue (always succeeds since pool size == queue size) + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. client->event_queue.push(event); // Re-enable component loop to process the queued event From 6154b673c2b6d25d05ec899731d3f4abd171f033 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:18:31 -1000 Subject: [PATCH 206/279] [usb_uart] Fix EventPool/LockFreeQueue sizing off-by-one (#14895) --- esphome/components/usb_uart/usb_uart.cpp | 11 +++++------ esphome/components/usb_uart/usb_uart.h | 8 ++++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3d35f368fb8..7c4358fdbd0 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -160,11 +160,9 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { size_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); chunk->length = static_cast(chunk_len); - if (!this->output_queue_.push(chunk)) { - this->output_pool_.release(chunk); - ESP_LOGE(TAG, "Output queue full - lost %zu bytes", len); - break; - } + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + this->output_queue_.push(chunk); data += chunk_len; len -= chunk_len; } @@ -320,7 +318,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { chunk->channel = channel; // Push to lock-free queue for main loop processing - // Push always succeeds because pool size == queue size + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. this->usb_data_queue_.push(chunk); // Re-enable component loop to process the queued data diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 16469df7f60..7a06b04f114 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -158,7 +158,10 @@ class USBUartChannel : public uart::UARTComponent, public Parented output_queue_; - EventPool output_pool_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements. This guarantees allocate() returns nullptr + // before push() can fail, preventing a pool slot leak. + EventPool output_pool_; std::function rx_callback_{}; CdcEps cdc_dev_{}; StringRef debug_prefix_{}; @@ -190,7 +193,8 @@ class USBUartComponent : public usb_host::USBClient { // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; - EventPool chunk_pool_; + // Pool sized to queue capacity (SIZE-1) — see USBUartChannel::output_pool_ comment. + EventPool chunk_pool_; protected: std::vector channels_{}; From ccf672d7ee37389dcd85f46f49ca0a5b5471f3d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 12:24:02 -1000 Subject: [PATCH 207/279] [esp32_ble] Fix EventPool/LockFreeQueue sizing off-by-one (#14892) --- esphome/components/esp32_ble/ble.cpp | 3 ++- esphome/components/esp32_ble/ble.h | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ff9d9bb15aa..fee1c546be4 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -575,8 +575,9 @@ template void enqueue_ble_event(Args... args) { load_ble_event(event, args...); // Push the event to the queue + // Push always succeeds: pool is sized to queue capacity (N-1), so if + // allocate() returned non-null, the queue is guaranteed to have room. global_ble->ble_events_.push(event); - // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size } // Explicit template instantiations for the friend function diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 04bec3f7858..752ddc9d1ff 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -221,7 +221,13 @@ class ESP32BLE : public Component { // Large objects (size depends on template parameters, but typically aligned to 4 bytes) esphome::LockFreeQueue ble_events_; - esphome::EventPool ble_event_pool_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements (one slot distinguishes full from empty). + // This guarantees allocate() returns nullptr before push() can fail, which: + // 1. Prevents leaking a pool slot (the Nth allocate succeeds but push fails) + // 2. Avoids needing release() on the producer path after a failed push(), + // preserving the SPSC contract on the pool's internal free list + esphome::EventPool ble_event_pool_; // 4-byte aligned members #ifdef USE_ESP32_BLE_ADVERTISING From 80bd6489cf12c6eadafe02604facb7f841cc5600 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:38:41 -1000 Subject: [PATCH 208/279] [esp32_ble_server] Remove vestigial semaphore from BLECharacteristic (#14900) --- .../components/esp32_ble_server/ble_characteristic.cpp | 10 +--------- .../components/esp32_ble_server/ble_characteristic.h | 4 ---- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 1806354712d..aa82b773ba6 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -16,13 +16,9 @@ BLECharacteristic::~BLECharacteristic() { for (auto *descriptor : this->descriptors_) { delete descriptor; // NOLINT(cppcoreguidelines-owning-memory) } - vSemaphoreDelete(this->set_value_lock_); } BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) : uuid_(uuid) { - this->set_value_lock_ = xSemaphoreCreateBinary(); - xSemaphoreGive(this->set_value_lock_); - this->properties_ = (esp_gatt_char_prop_t) 0; this->set_broadcast_property((properties & PROPERTY_BROADCAST) != 0); @@ -35,11 +31,7 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } -void BLECharacteristic::set_value(std::vector &&buffer) { - xSemaphoreTake(this->set_value_lock_, 0L); - this->value_ = std::move(buffer); - xSemaphoreGive(this->set_value_lock_); -} +void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { this->set_value(std::vector(data)); // Delegate to move overload diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 72897d1dfbe..062052cdf8c 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -16,8 +16,6 @@ #include #include #include -#include -#include namespace esphome { namespace esp32_ble_server { @@ -84,8 +82,6 @@ class BLECharacteristic { uint16_t value_read_offset_{0}; std::vector value_; - SemaphoreHandle_t set_value_lock_; - std::vector descriptors_; struct ClientNotificationEntry { From be2e4a5278a83155fa3128e68c4b9824c311f1cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 13:49:24 -1000 Subject: [PATCH 209/279] [mqtt] Fix data race on inbound event queue (#14891) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/mqtt/mqtt_backend_esp32.cpp | 34 ++++++--- esphome/components/mqtt/mqtt_backend_esp32.h | 69 +++++++++++-------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 5642fd5f7b6..ab067c44182 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -82,10 +82,16 @@ bool MQTTBackendESP32::initialize_() { void MQTTBackendESP32::loop() { // process new events // handle only 1 message per loop iteration - if (!mqtt_events_.empty()) { - auto &event = mqtt_events_.front(); - mqtt_event_handler_(event); - mqtt_events_.pop(); + Event *event = this->mqtt_event_queue_.pop(); + if (event != nullptr) { + this->mqtt_event_handler_(*event); + this->mqtt_event_pool_.release(event); + } + + // Log dropped inbound events (check is cheap - single atomic load in common case) + uint16_t inbound_dropped = this->mqtt_event_queue_.get_and_reset_dropped_count(); + if (inbound_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u inbound MQTT events", inbound_dropped); } #if defined(USE_MQTT_IDF_ENQUEUE) @@ -183,10 +189,18 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { MQTTBackendESP32 *instance = static_cast(handler_args); - // queue event to decouple processing + // queue event to decouple processing from ESP-IDF MQTT task to main loop if (instance) { - auto event = *static_cast(event_data); - instance->mqtt_events_.emplace(event); + auto *event = instance->mqtt_event_pool_.allocate(); + if (event == nullptr) { + // Pool exhausted, drop event (counted via queue's dropped counter) + instance->mqtt_event_queue_.increment_dropped_count(); + return; + } + event->populate(*static_cast(event_data)); + // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if + // allocate() returned non-null, the queue cannot be full. + instance->mqtt_event_queue_.push(event); // Wake main loop immediately to process MQTT event instead of waiting for select() timeout #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) @@ -226,14 +240,14 @@ void MQTTBackendESP32::esphome_mqtt_task(void *params) { break; } } - this_mqtt->mqtt_event_pool_.release(elem); + this_mqtt->mqtt_outbound_pool_.release(elem); } } } bool MQTTBackendESP32::enqueue_(MqttQueueTypeT type, const char *topic, int qos, bool retain, const char *payload, size_t len) { - auto *elem = this->mqtt_event_pool_.allocate(); + auto *elem = this->mqtt_outbound_pool_.allocate(); if (!elem) { // Queue is full - increment counter but don't log immediately. @@ -253,7 +267,7 @@ bool MQTTBackendESP32::enqueue_(MqttQueueTypeT type, const char *topic, int qos, // Use the helper to allocate and copy data if (!elem->set_data(topic, payload, len)) { // Allocation failed, return elem to pool - this->mqtt_event_pool_.release(elem); + this->mqtt_outbound_pool_.release(elem); // Increment counter without logging to avoid cascade effect during memory pressure this->mqtt_queue_.increment_dropped_count(); return false; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index 5c4dc413bdc..58d1b29b325 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -5,7 +5,6 @@ #ifdef USE_ESP32 #include -#include #include #include #include @@ -18,32 +17,39 @@ namespace esphome::mqtt { struct Event { - esp_mqtt_event_id_t event_id; + esp_mqtt_event_id_t event_id{}; std::vector data; - int total_data_len; - int current_data_offset; + int total_data_len{0}; + int current_data_offset{0}; std::string topic; - int msg_id; - bool retain; - int qos; - bool dup; - bool session_present; - esp_mqtt_error_codes_t error_handle; + int msg_id{0}; + bool retain{false}; + int qos{0}; + bool dup{false}; + bool session_present{false}; + esp_mqtt_error_codes_t error_handle{}; - // Construct from esp_mqtt_event_t - // Any pointer values that are unsafe to keep are converted to safe copies - Event(const esp_mqtt_event_t &event) - : event_id(event.event_id), - data(event.data, event.data + event.data_len), - total_data_len(event.total_data_len), - current_data_offset(event.current_data_offset), - topic(event.topic, event.topic_len), - msg_id(event.msg_id), - retain(event.retain), - qos(event.qos), - dup(event.dup), - session_present(event.session_present), - error_handle(*event.error_handle) {} + // Populate from esp_mqtt_event_t + // Copies pointer-based data to owned storage for safe cross-thread transfer + void populate(const esp_mqtt_event_t &event) { + this->event_id = event.event_id; + this->data.assign(event.data, event.data + event.data_len); + this->total_data_len = event.total_data_len; + this->current_data_offset = event.current_data_offset; + this->topic.assign(event.topic, event.topic_len); + this->msg_id = event.msg_id; + this->retain = event.retain; + this->qos = event.qos; + this->dup = event.dup; + this->session_present = event.session_present; + this->error_handle = *event.error_handle; + } + + // Release owned resources for pool reuse (keeps allocated capacity for efficiency) + void release() { + this->data.clear(); + this->topic.clear(); + } }; enum MqttQueueTypeT : uint8_t { @@ -118,7 +124,8 @@ class MQTTBackendESP32 final : public MQTTBackend { static constexpr size_t TASK_STACK_SIZE = 3072; static constexpr size_t TASK_STACK_SIZE_TLS = 4096; // Larger stack for TLS operations static constexpr ssize_t TASK_PRIORITY = 5; - static constexpr uint8_t MQTT_QUEUE_LENGTH = 30; // 30*12 bytes = 360 + static constexpr uint8_t MQTT_QUEUE_LENGTH = 30; // 30*12 bytes = 360 + static constexpr uint8_t MQTT_EVENT_QUEUE_LENGTH = 32; // Inbound events from broker void set_keep_alive(uint16_t keep_alive) final { this->keep_alive_ = keep_alive; } void set_client_id(const char *client_id) final { this->client_id_ = client_id; } @@ -251,7 +258,8 @@ class MQTTBackendESP32 final : public MQTTBackend { bool skip_cert_cn_check_{false}; #if defined(USE_MQTT_IDF_ENQUEUE) static void esphome_mqtt_task(void *params); - EventPool mqtt_event_pool_; + // Pool sized to queue capacity (SIZE-1) — see mqtt_event_pool_ comment. + EventPool mqtt_outbound_pool_; NotifyingLockFreeQueue mqtt_queue_; TaskHandle_t task_handle_{nullptr}; bool enqueue_(MqttQueueTypeT type, const char *topic, int qos = 0, bool retain = false, const char *payload = NULL, @@ -266,7 +274,14 @@ class MQTTBackendESP32 final : public MQTTBackend { CallbackManager on_message_; CallbackManager on_publish_; std::string cached_topic_; - std::queue mqtt_events_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements (one slot distinguishes full from empty). + // This guarantees allocate() returns nullptr before push() can fail, which: + // 1. Prevents leaking a pool slot (the Nth allocate succeeds but push fails) + // 2. Avoids needing release() on the producer path after a failed push(), + // preserving the SPSC contract on the pool's internal free list + EventPool mqtt_event_pool_; + LockFreeQueue mqtt_event_queue_; #if defined(USE_MQTT_IDF_ENQUEUE) uint32_t last_dropped_log_time_{0}; From 0fa96b6e1ea76b46a19d8cc4f847dcea3606452a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 14:19:31 -1000 Subject: [PATCH 210/279] [scheduler] Fix UB in cross-thread counter/vector reads, add atomic fast-path (#14880) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/core/scheduler.cpp | 48 ++++++----- esphome/core/scheduler.h | 159 ++++++++++++++++++++++++++++++++++--- 2 files changed, 177 insertions(+), 30 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 63e1006b03c..8c4ff0ddb50 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -211,6 +211,14 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } target->push_back(item); + if (target == &this->to_add_) { + this->to_add_count_increment_(); + } +#ifndef ESPHOME_THREAD_SINGLE + else { + this->defer_count_increment_(); + } +#endif } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, @@ -387,7 +395,7 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { // safe when called from the main thread. Other threads must not call this method. // If no items, return empty optional - if (this->cleanup_() == 0) + if (!this->cleanup_()) return {}; SchedulerItem *item = this->items_[0]; @@ -421,7 +429,7 @@ void Scheduler::full_cleanup_removed_items_() { this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_ = 0; + this->to_remove_clear_(); } #ifndef ESPHOME_THREAD_SINGLE @@ -502,7 +510,7 @@ void HOT Scheduler::call(uint32_t now) { // If we still have too many cancelled items, do a full cleanup // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { this->full_cleanup_removed_items_(); } while (!this->items_.empty()) { @@ -529,7 +537,7 @@ void HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_--; + this->to_remove_decrement_(); continue; } } @@ -538,7 +546,7 @@ void HOT Scheduler::call(uint32_t now) { if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_--; + this->to_remove_decrement_(); continue; } #endif @@ -566,7 +574,7 @@ void HOT Scheduler::call(uint32_t now) { if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue - this->to_remove_--; + this->to_remove_decrement_(); this->recycle_item_main_loop_(executed_item); continue; } @@ -576,6 +584,7 @@ void HOT Scheduler::call(uint32_t now) { // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(executed_item); + this->to_add_count_increment_(); } else { // Timeout completed - recycle it this->recycle_item_main_loop_(executed_item); @@ -604,6 +613,10 @@ void HOT Scheduler::call(uint32_t now) { #endif } void HOT Scheduler::process_to_add() { + // Fast path: skip lock acquisition when nothing to add. + // Worst case is a one-loop-iteration delay before newly added items are processed. + if (this->to_add_empty_()) + return; LockGuard guard{this->lock_}; for (auto *&it : this->to_add_) { if (is_item_removed_locked_(it)) { @@ -617,17 +630,14 @@ void HOT Scheduler::process_to_add() { std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); + this->to_add_count_clear_(); } -size_t HOT Scheduler::cleanup_() { - // Fast path: if nothing to remove, just return the current size - // Reading to_remove_ without lock is safe because: - // 1. We only call this from the main thread during call() - // 2. If it's 0, there's definitely nothing to cleanup - // 3. If it becomes non-zero after we check, cleanup will happen on the next loop iteration - // 4. Not all platforms support atomics, so we accept this race in favor of performance - // 5. The worst case is a one-loop-iteration delay in cleanup, which is harmless - if (this->to_remove_ == 0) - return this->items_.size(); +bool HOT Scheduler::cleanup_() { + // Fast path: if nothing to remove, just check if items exist. + // Uses atomic load on platforms with atomics, falls back to always taking the lock otherwise. + // Worst case is a one-loop-iteration delay in cleanup. + if (this->to_remove_empty_()) + return !this->items_.empty(); // We must hold the lock for the entire cleanup operation because: // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access @@ -642,10 +652,10 @@ size_t HOT Scheduler::cleanup_() { SchedulerItem *item = this->items_[0]; if (!this->is_item_removed_locked_(item)) break; - this->to_remove_--; + this->to_remove_decrement_(); this->recycle_item_main_loop_(this->pop_raw_locked_()); } - return this->items_.size(); + return !this->items_.empty(); } Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); @@ -698,7 +708,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry); total_cancelled += heap_cancelled; - this->to_remove_ += heap_cancelled; + this->to_remove_add_(heap_cancelled); } // Cancel items in to_add_ diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 0476513bb99..e545055fcae 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -284,9 +284,9 @@ class Scheduler { #endif } // Cleanup logically deleted items from the scheduler - // Returns the number of items remaining after cleanup + // Returns true if items remain after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). - size_t cleanup_(); + bool cleanup_(); // Remove and return the front item from the heap as a raw pointer. // Caller takes ownership and must either recycle or delete the item. // IMPORTANT: Caller must hold the scheduler lock before calling this function. @@ -395,15 +395,9 @@ class Scheduler { // erase() on every pop, which would be O(n). The queue is processed once per loop - // any items added during processing are left for the next loop iteration. - // Snapshot the queue end point - only process items that existed at loop start - // Items added during processing (by callbacks or other threads) run next loop - // No lock needed: single consumer (main loop), stale read just means we process less this iteration - size_t defer_queue_end = this->defer_queue_.size(); - // Fast path: nothing to process, avoid lock entirely. - // Safe without lock: single consumer (main loop) reads front_, and a stale size() read - // from a concurrent push can only make us see fewer items — they'll be processed next loop. - if (this->defer_queue_front_ >= defer_queue_end) + // Worst case is a one-loop-iteration delay before newly deferred items are processed. + if (this->defer_empty_()) return; // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), @@ -412,6 +406,13 @@ class Scheduler { SchedulerItem *item; this->lock_.lock(); + // Reset counter and snapshot queue end under lock + this->defer_count_clear_(); + size_t defer_queue_end = this->defer_queue_.size(); + if (this->defer_queue_front_ >= defer_queue_end) { + this->lock_.unlock(); + return; + } while (this->defer_queue_front_ < defer_queue_end) { // Take ownership of the item, leaving nullptr in the vector slot. // This is safe because: @@ -527,14 +528,150 @@ class Scheduler { Mutex lock_; std::vector items_; std::vector to_add_; + +#ifndef ESPHOME_THREAD_SINGLE + // Fast-path counter for process_to_add() to skip taking the lock when there is + // nothing to add. Uses std::atomic on platforms that support it, plain uint32_t + // otherwise. On non-atomic platforms, callers must hold the scheduler lock when + // mutating this counter. Not needed on single-threaded platforms where we can + // check to_add_.empty() directly. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + std::atomic to_add_count_{0}; +#else + uint32_t to_add_count_{0}; +#endif +#endif /* ESPHOME_THREAD_SINGLE */ + + // Fast-path helper for process_to_add() to decide if it can try the lock-free path. + // - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers). + // - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_. + // - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller + // down the locked path; this is NOT a lock-free emptiness check on that platform. + bool to_add_empty_() const { +#ifdef ESPHOME_THREAD_SINGLE + return this->to_add_.empty(); +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) + return this->to_add_count_.load(std::memory_order_relaxed) == 0; +#else + return false; +#endif + } + + // Increment to_add_count_ (no-op on single-threaded platforms) + void to_add_count_increment_() { +#ifdef ESPHOME_THREAD_SINGLE + // No counter needed — to_add_empty_() checks the vector directly +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) + this->to_add_count_.fetch_add(1, std::memory_order_relaxed); +#else + this->to_add_count_++; +#endif + } + + // Reset to_add_count_ (no-op on single-threaded platforms) + void to_add_count_clear_() { +#ifdef ESPHOME_THREAD_SINGLE + // No counter needed — to_add_empty_() checks the vector directly +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) + this->to_add_count_.store(0, std::memory_order_relaxed); +#else + this->to_add_count_ = 0; +#endif + } + #ifndef ESPHOME_THREAD_SINGLE // Single-core platforms don't need the defer queue and save ~32 bytes of RAM // Using std::vector instead of std::deque avoids 512-byte chunked allocations // Index tracking avoids O(n) erase() calls when draining the queue each loop std::vector defer_queue_; // FIFO queue for defer() calls size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) -#endif /* ESPHOME_THREAD_SINGLE */ + + // Fast-path counter for process_defer_queue_() to skip lock when nothing to process. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + std::atomic defer_count_{0}; +#else + uint32_t defer_count_{0}; +#endif + + bool defer_empty_() const { + // defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path + // ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return this->defer_count_.load(std::memory_order_relaxed) == 0; +#else + return false; +#endif + } + + void defer_count_increment_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->defer_count_.fetch_add(1, std::memory_order_relaxed); +#else + this->defer_count_++; +#endif + } + + void defer_count_clear_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->defer_count_.store(0, std::memory_order_relaxed); +#else + this->defer_count_ = 0; +#endif + } + +#endif /* ESPHOME_THREAD_SINGLE */ + + // Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_(). + // On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path; + // on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + std::atomic to_remove_{0}; +#else uint32_t to_remove_{0}; +#endif + + // Lock-free check if there are items to remove (for fast-path in cleanup_) + bool to_remove_empty_() const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return this->to_remove_.load(std::memory_order_relaxed) == 0; +#elif defined(ESPHOME_THREAD_SINGLE) + return this->to_remove_ == 0; +#else + return false; // Always take the lock path +#endif + } + + void to_remove_add_(uint32_t count) { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#else + this->to_remove_ += count; +#endif + } + + void to_remove_decrement_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#else + this->to_remove_--; +#endif + } + + void to_remove_clear_() { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + this->to_remove_.store(0, std::memory_order_relaxed); +#else + this->to_remove_ = 0; +#endif + } + + uint32_t to_remove_count_() const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return this->to_remove_.load(std::memory_order_relaxed); +#else + return this->to_remove_; +#endif + } // Memory pool for recycling SchedulerItem objects to reduce heap churn. // Design decisions: From 5cc03d9befb7326fe708e7368778416eddd47253 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:35:21 +1300 Subject: [PATCH 211/279] Bump version to 2026.3.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 96295b3fc8e..ea34106f36a 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.3.0b3 +PROJECT_NUMBER = 2026.3.0b4 # 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 561a27d2286..4ba8b1a23fc 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0b3" +__version__ = "2026.3.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3e845d387a482db37a7c4dfea4692b974ffa9566 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Mar 2026 14:44:17 -1000 Subject: [PATCH 212/279] [tests] Fix test_show_logs_serial taking 30s due to unmocked serial port wait (#14903) --- tests/unit_tests/test_main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b8534611517..5e36c06bb31 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -167,6 +167,13 @@ def mock_run_miniterm() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_wait_for_serial_port() -> Generator[Mock]: + """Mock _wait_for_serial_port for testing.""" + with patch("esphome.__main__._wait_for_serial_port") as mock: + yield mock + + @pytest.fixture def mock_upload_using_esptool() -> Generator[Mock]: """Mock upload_using_esptool for testing.""" @@ -1706,6 +1713,7 @@ def test_show_logs_serial( mock_get_port_type: Mock, mock_check_permissions: Mock, mock_run_miniterm: Mock, + mock_wait_for_serial_port: Mock, ) -> None: """Test show_logs with serial port.""" setup_core(config={"logger": {}}, platform=PLATFORM_ESP32) From 2531fb1a021cf30e238754de5194e77a05be1800 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:12:13 -0400 Subject: [PATCH 213/279] [voice_assistant][micro_wake_word] Fix null deref and missing error return (#14906) --- esphome/components/micro_wake_word/streaming_model.cpp | 1 + esphome/components/voice_assistant/voice_assistant.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 47d2c70e138..0ab6cd3772d 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -80,6 +80,7 @@ bool StreamingModel::load_model_() { TfLiteTensor *output = this->interpreter_->output(0); if ((output->dims->size != 2) || (output->dims->data[0] != 1) || (output->dims->data[1] != 1)) { ESP_LOGE(TAG, "Streaming model tensor output dimension is not 1x1."); + return false; } if (output->type != kTfLiteUInt8) { diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 51d52a8af8d..15124e422ff 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -619,6 +619,8 @@ void VoiceAssistant::start_playback_timeout_() { this->cancel_timeout("speaker-timeout"); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); + if (this->api_client_ == nullptr) + return; api::VoiceAssistantAnnounceFinished msg; msg.success = true; this->api_client_->send_message(msg); From 16c52243416332d18f2ff066c378b2968dbd5083 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:48:43 -0400 Subject: [PATCH 214/279] [tc74][apds9960] Fix signed temperature and FIFO register address (#14907) --- esphome/components/apds9960/apds9960.cpp | 8 ++++---- esphome/components/tc74/tc74.cpp | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index 260de82d143..a07175f2c9c 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -251,11 +251,11 @@ void APDS9960::read_gesture_data_() { uint8_t buf[128]; for (uint8_t pos = 0; pos < fifo_level * 4; pos += 32) { - // The ESP's i2c driver has a limited buffer size. - // This way of retrieving the data should be wrong according to the datasheet - // but it seems to work. + // Read in 32-byte chunks due to ESP8266 I2C buffer limit. + // Always read from 0xFC — the FIFO auto-increments through 0xFC-0xFF + // and advances its internal pointer after every 4th byte. uint8_t read = std::min(32, fifo_level * 4 - pos); - APDS9960_WARNING_CHECK(this->read_bytes(0xFC + pos, buf + pos, read), "Reading FIFO buffer failed."); + APDS9960_WARNING_CHECK(this->read_bytes(0xFC, buf + pos, read), "Reading FIFO buffer failed."); } if (millis() - this->gesture_start_ > 500) { diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index 969ef3671e2..cb58e583dca 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -50,8 +50,9 @@ void TC74Component::read_temperature_() { } } - uint8_t temperature_reg; - if (this->read_register(TC74_REGISTER_TEMPERATURE, &temperature_reg, 1) != i2c::ERROR_OK) { + int8_t temperature_reg; + if (this->read_register(TC74_REGISTER_TEMPERATURE, reinterpret_cast(&temperature_reg), 1) != + i2c::ERROR_OK) { this->status_set_warning(); return; } From 1d07f37d6215f35980946d5f543d97347d15e797 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:22:28 -0400 Subject: [PATCH 215/279] [opentherm] Migrate from legacy timer API to GPTimer API (#14859) --- esphome/components/opentherm/__init__.py | 5 +- esphome/components/opentherm/opentherm.cpp | 89 +++++++--------------- esphome/components/opentherm/opentherm.h | 18 +++-- 3 files changed, 40 insertions(+), 72 deletions(-) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 36f85a97661..85632d0bf87 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -81,10 +81,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: dict[str, Any]) -> None: if CORE.is_esp32: - # Re-enable ESP-IDF's legacy driver component (excluded by default to save compile time) - # Provides driver/timer.h header for hardware timer API - # TODO: Remove this once opentherm migrates to GPTimer API (driver/gptimer.h) - include_builtin_idf_component("driver") + include_builtin_idf_component("esp_driver_gptimer") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index cdf89207bcf..97cf83a5aa2 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -8,10 +8,7 @@ #include "opentherm.h" #include "esphome/core/helpers.h" #include -// TODO: Migrate from legacy timer API (driver/timer.h) to GPTimer API (driver/gptimer.h) -// The legacy timer API is deprecated in ESP-IDF 5.x. See opentherm.h for details. #ifdef USE_ESP32 -#include "driver/timer.h" #include "esp_err.h" #endif #ifdef ESP8266 @@ -33,10 +30,6 @@ OpenTherm *OpenTherm::instance = nullptr; OpenTherm::OpenTherm(InternalGPIOPin *in_pin, InternalGPIOPin *out_pin, int32_t device_timeout) : in_pin_(in_pin), out_pin_(out_pin), -#ifdef USE_ESP32 - timer_group_(TIMER_GROUP_0), - timer_idx_(TIMER_0), -#endif mode_(OperationMode::IDLE), error_type_(ProtocolErrorType::NO_ERROR), capture_(0), @@ -134,7 +127,12 @@ void IRAM_ATTR OpenTherm::read_() { // period in OpenTherm. } +#ifdef USE_ESP32 +bool IRAM_ATTR OpenTherm::timer_isr(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user_ctx) { + auto *arg = static_cast(user_ctx); +#else bool IRAM_ATTR OpenTherm::timer_isr(OpenTherm *arg) { +#endif if (arg->mode_ == OperationMode::LISTEN) { if (arg->timeout_counter_ == 0) { arg->mode_ = OperationMode::ERROR_TIMEOUT; @@ -243,67 +241,35 @@ void IRAM_ATTR OpenTherm::write_bit_(uint8_t high, uint8_t clock) { #ifdef USE_ESP32 bool OpenTherm::init_esp32_timer_() { - // Search for a free timer. Maybe unstable, we'll see. - int cur_timer = 0; - timer_group_t timer_group = TIMER_GROUP_0; - timer_idx_t timer_idx = TIMER_0; - bool timer_found = false; - - for (; cur_timer < SOC_TIMER_GROUP_TOTAL_TIMERS; cur_timer++) { - timer_config_t temp_config; - timer_group = cur_timer < 2 ? TIMER_GROUP_0 : TIMER_GROUP_1; - timer_idx = cur_timer < 2 ? (timer_idx_t) cur_timer : (timer_idx_t) (cur_timer - 2); - - auto err = timer_get_config(timer_group, timer_idx, &temp_config); - if (err == ESP_ERR_INVALID_ARG) { - // Error means timer was not initialized (or other things, but we are careful with our args) - timer_found = true; - break; - } - - ESP_LOGD(TAG, "Timer %d:%d seems to be occupied, will try another", timer_group, timer_idx); - } - - if (!timer_found) { - ESP_LOGE(TAG, "No free timer was found! OpenTherm cannot function without a timer."); - return false; - } - - ESP_LOGD(TAG, "Found free timer %d:%d", timer_group, timer_idx); - this->timer_group_ = timer_group; - this->timer_idx_ = timer_idx; - - timer_config_t const config = { - .alarm_en = TIMER_ALARM_EN, - .counter_en = TIMER_PAUSE, - .intr_type = TIMER_INTR_LEVEL, - .counter_dir = TIMER_COUNT_UP, - .auto_reload = TIMER_AUTORELOAD_EN, - .clk_src = TIMER_SRC_CLK_DEFAULT, - .divider = 80, + // 80MHz / 80 = 1MHz resolution (1µs per tick) + gptimer_config_t config = { + .clk_src = GPTIMER_CLK_SRC_DEFAULT, + .direction = GPTIMER_COUNT_UP, + .resolution_hz = 1000000, }; - esp_err_t result; - - result = timer_init(this->timer_group_, this->timer_idx_, &config); + esp_err_t result = gptimer_new_timer(&config, &this->timer_handle_); if (result != ESP_OK) { - const auto *error = esp_err_to_name(result); - ESP_LOGE(TAG, "Failed to init timer. Error: %s", error); + ESP_LOGE(TAG, "Failed to create timer: %s", esp_err_to_name(result)); return false; } - result = timer_set_counter_value(this->timer_group_, this->timer_idx_, 0); + gptimer_event_callbacks_t cbs = { + .on_alarm = OpenTherm::timer_isr, + }; + result = gptimer_register_event_callbacks(this->timer_handle_, &cbs, this); if (result != ESP_OK) { - const auto *error = esp_err_to_name(result); - ESP_LOGE(TAG, "Failed to set counter value. Error: %s", error); + ESP_LOGE(TAG, "Failed to register timer callback: %s", esp_err_to_name(result)); + gptimer_del_timer(this->timer_handle_); + this->timer_handle_ = nullptr; return false; } - result = timer_isr_callback_add(this->timer_group_, this->timer_idx_, reinterpret_cast(timer_isr), - this, 0); + result = gptimer_enable(this->timer_handle_); if (result != ESP_OK) { - const auto *error = esp_err_to_name(result); - ESP_LOGE(TAG, "Failed to register timer interrupt. Error: %s", error); + ESP_LOGE(TAG, "Failed to enable timer: %s", esp_err_to_name(result)); + gptimer_del_timer(this->timer_handle_); + this->timer_handle_ = nullptr; return false; } @@ -315,12 +281,13 @@ void IRAM_ATTR OpenTherm::start_esp32_timer_(uint64_t alarm_value) { this->timer_error_ = ESP_OK; this->timer_error_type_ = TimerErrorType::NO_TIMER_ERROR; - this->timer_error_ = timer_set_alarm_value(this->timer_group_, this->timer_idx_, alarm_value); + this->alarm_config_.alarm_count = alarm_value; + this->timer_error_ = gptimer_set_alarm_action(this->timer_handle_, &this->alarm_config_); if (this->timer_error_ != ESP_OK) { this->timer_error_type_ = TimerErrorType::SET_ALARM_VALUE_ERROR; return; } - this->timer_error_ = timer_start(this->timer_group_, this->timer_idx_); + this->timer_error_ = gptimer_start(this->timer_handle_); if (this->timer_error_ != ESP_OK) { this->timer_error_type_ = TimerErrorType::TIMER_START_ERROR; } @@ -356,12 +323,12 @@ void IRAM_ATTR OpenTherm::stop_timer_() { this->timer_error_ = ESP_OK; this->timer_error_type_ = TimerErrorType::NO_TIMER_ERROR; - this->timer_error_ = timer_pause(this->timer_group_, this->timer_idx_); + this->timer_error_ = gptimer_stop(this->timer_handle_); if (this->timer_error_ != ESP_OK) { this->timer_error_type_ = TimerErrorType::TIMER_PAUSE_ERROR; return; } - this->timer_error_ = timer_set_counter_value(this->timer_group_, this->timer_idx_, 0); + this->timer_error_ = gptimer_set_raw_count(this->timer_handle_, 0); if (this->timer_error_ != ESP_OK) { this->timer_error_type_ = TimerErrorType::SET_COUNTER_VALUE_ERROR; } diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index a2c347d0d8e..eb8c5b3ad6c 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -12,12 +12,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -// TODO: Migrate from legacy timer API (driver/timer.h) to GPTimer API (driver/gptimer.h) -// The legacy timer API is deprecated in ESP-IDF 5.x. Migration would allow removing the -// "driver" IDF component dependency. See: -// https://docs.espressif.com/projects/esp-idf/en/latest/esp32/migration-guides/release-5.x/5.0/peripherals.html#id4 #ifdef USE_ESP32 -#include "driver/timer.h" +#include "driver/gptimer.h" #endif namespace esphome { @@ -348,7 +344,11 @@ class OpenTherm { const char *operation_mode_to_str(OperationMode mode); const char *message_id_to_str(MessageId id); +#ifdef USE_ESP32 + static bool timer_isr(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user_ctx); +#else static bool timer_isr(OpenTherm *arg); +#endif #ifdef ESP8266 static void esp8266_timer_isr(); @@ -361,8 +361,12 @@ class OpenTherm { ISRInternalGPIOPin isr_out_pin_; #ifdef USE_ESP32 - timer_group_t timer_group_; - timer_idx_t timer_idx_; + gptimer_handle_t timer_handle_{nullptr}; + gptimer_alarm_config_t alarm_config_{ + .alarm_count = 0, + .reload_count = 0, + .flags = {.auto_reload_on_alarm = true}, + }; #endif OperationMode mode_; From 3f28ab88cafb51f04cbef929a2b525be03f64c83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Mar 2026 07:46:18 -1000 Subject: [PATCH 216/279] [http_request] Fix data race on update_info_ strings in update task (#14909) --- .../update/http_request_update.cpp | 222 ++++++++++-------- 1 file changed, 121 insertions(+), 101 deletions(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c40590af955..a15dc616753 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -23,6 +23,12 @@ namespace http_request { static const char *const TAG = "http_request.update"; +// Wraps UpdateInfo + error for the task→main-loop handoff. +struct TaskResult { + update::UpdateInfo info; + const LogString *error_str{nullptr}; +}; + static const size_t MAX_READ_SIZE = 256; static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000; @@ -77,134 +83,148 @@ void HttpRequestUpdate::update() { void HttpRequestUpdate::update_task(void *params) { HttpRequestUpdate *this_update = (HttpRequestUpdate *) params; + // Allocate once — every path below returns via the single defer at the end. + // On failure, error_str is set; on success it is nullptr. + auto *result = new TaskResult(); + auto *info = &result->info; + auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to fetch manifest")); }); - UPDATE_RETURN; + if (container != nullptr) + container->end(); + result->error_str = LOG_STR("Failed to fetch manifest"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } - RAMAllocator allocator; - uint8_t *data = allocator.allocate(container->content_length); - if (data == nullptr) { - ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer( - [this_update]() { this_update->status_set_error(LOG_STR("Failed to allocate memory for manifest")); }); - container->end(); - UPDATE_RETURN; - } - - auto read_result = http_read_fully(container.get(), data, container->content_length, MAX_READ_SIZE, - this_update->request_parent_->get_timeout()); - if (read_result.status != HttpReadStatus::OK) { - if (read_result.status == HttpReadStatus::TIMEOUT) { - ESP_LOGE(TAG, "Timeout reading manifest"); - } else { - ESP_LOGE(TAG, "Error reading manifest: %d", read_result.error_code); + { + RAMAllocator allocator; + uint8_t *data = allocator.allocate(container->content_length); + if (data == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); + container->end(); + result->error_str = LOG_STR("Failed to allocate memory for manifest"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to read manifest")); }); - allocator.deallocate(data, container->content_length); - container->end(); - UPDATE_RETURN; - } - size_t read_index = container->get_bytes_read(); - size_t content_length = container->content_length; - container->end(); - container.reset(); // Release ownership of the container's shared_ptr - - bool valid = false; - { // Scope to ensure JsonDocument is destroyed before deallocating buffer - valid = json::parse_json(data, read_index, [this_update](JsonObject root) -> bool { - if (!root[ESPHOME_F("name")].is() || !root[ESPHOME_F("version")].is() || - !root[ESPHOME_F("builds")].is()) { - ESP_LOGE(TAG, "Manifest does not contain required fields"); - return false; + auto read_result = http_read_fully(container.get(), data, container->content_length, MAX_READ_SIZE, + this_update->request_parent_->get_timeout()); + if (read_result.status != HttpReadStatus::OK) { + if (read_result.status == HttpReadStatus::TIMEOUT) { + ESP_LOGE(TAG, "Timeout reading manifest"); + } else { + ESP_LOGE(TAG, "Error reading manifest: %d", read_result.error_code); } - this_update->update_info_.title = root[ESPHOME_F("name")].as(); - this_update->update_info_.latest_version = root[ESPHOME_F("version")].as(); + allocator.deallocate(data, container->content_length); + container->end(); + result->error_str = LOG_STR("Failed to read manifest"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) + } + size_t read_index = container->get_bytes_read(); + size_t content_length = container->content_length; - auto builds_array = root[ESPHOME_F("builds")].as(); - for (auto build : builds_array) { - if (!build[ESPHOME_F("chipFamily")].is()) { + container->end(); + container.reset(); // Release ownership of the container's shared_ptr + + bool valid = false; + { // Scope to ensure JsonDocument is destroyed before deallocating buffer + valid = json::parse_json(data, read_index, [info](JsonObject root) -> bool { + if (!root[ESPHOME_F("name")].is() || !root[ESPHOME_F("version")].is() || + !root[ESPHOME_F("builds")].is()) { ESP_LOGE(TAG, "Manifest does not contain required fields"); return false; } - if (build[ESPHOME_F("chipFamily")] == ESPHOME_VARIANT) { - if (!build[ESPHOME_F("ota")].is()) { + info->title = root[ESPHOME_F("name")].as(); + info->latest_version = root[ESPHOME_F("version")].as(); + + auto builds_array = root[ESPHOME_F("builds")].as(); + for (auto build : builds_array) { + if (!build[ESPHOME_F("chipFamily")].is()) { ESP_LOGE(TAG, "Manifest does not contain required fields"); return false; } - JsonObject ota = build[ESPHOME_F("ota")].as(); - if (!ota[ESPHOME_F("path")].is() || !ota[ESPHOME_F("md5")].is()) { - ESP_LOGE(TAG, "Manifest does not contain required fields"); - return false; + if (build[ESPHOME_F("chipFamily")] == ESPHOME_VARIANT) { + if (!build[ESPHOME_F("ota")].is()) { + ESP_LOGE(TAG, "Manifest does not contain required fields"); + return false; + } + JsonObject ota = build[ESPHOME_F("ota")].as(); + if (!ota[ESPHOME_F("path")].is() || !ota[ESPHOME_F("md5")].is()) { + ESP_LOGE(TAG, "Manifest does not contain required fields"); + return false; + } + info->firmware_url = ota[ESPHOME_F("path")].as(); + info->md5 = ota[ESPHOME_F("md5")].as(); + + if (ota[ESPHOME_F("summary")].is()) + info->summary = ota[ESPHOME_F("summary")].as(); + if (ota[ESPHOME_F("release_url")].is()) + info->release_url = ota[ESPHOME_F("release_url")].as(); + + return true; } - this_update->update_info_.firmware_url = ota[ESPHOME_F("path")].as(); - this_update->update_info_.md5 = ota[ESPHOME_F("md5")].as(); - - if (ota[ESPHOME_F("summary")].is()) - this_update->update_info_.summary = ota[ESPHOME_F("summary")].as(); - if (ota[ESPHOME_F("release_url")].is()) - this_update->update_info_.release_url = ota[ESPHOME_F("release_url")].as(); - - return true; } - } - return false; - }); - } - allocator.deallocate(data, content_length); + return false; + }); + } + allocator.deallocate(data, content_length); - if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to parse manifest JSON")); }); - UPDATE_RETURN; - } + if (!valid) { + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + result->error_str = LOG_STR("Failed to parse manifest JSON"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) + } - // Merge source_url_ and this_update->update_info_.firmware_url - if (this_update->update_info_.firmware_url.find("http") == std::string::npos) { - std::string path = this_update->update_info_.firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - this_update->update_info_.firmware_url = domain + path; - } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - this_update->update_info_.firmware_url = domain + path; + // Merge source_url_ and firmware_url + if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { + std::string path = info->firmware_url; + if (path[0] == '/') { + std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); + info->firmware_url = domain + path; + } else { + std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); + info->firmware_url = domain + path; + } } - } #ifdef ESPHOME_PROJECT_VERSION - this_update->update_info_.current_version = ESPHOME_PROJECT_VERSION; + info->current_version = ESPHOME_PROJECT_VERSION; #else - this_update->update_info_.current_version = ESPHOME_VERSION; + info->current_version = ESPHOME_VERSION; #endif - - bool trigger_update_available = false; - - if (this_update->update_info_.latest_version.empty() || - this_update->update_info_.latest_version == this_update->update_info_.current_version) { - this_update->state_ = update::UPDATE_STATE_NO_UPDATE; - } else { - if (this_update->state_ != update::UPDATE_STATE_AVAILABLE) { - trigger_update_available = true; - } - this_update->state_ = update::UPDATE_STATE_AVAILABLE; } - // Defer to main loop to ensure thread-safe execution of: - // - status_clear_error() performs non-atomic read-modify-write on component_state_ - // - publish_state() triggers API callbacks that write to the shared protobuf buffer - // which can be corrupted if accessed concurrently from task and main loop threads - // - update_available trigger to ensure consistent state when the trigger fires - this_update->defer([this_update, trigger_update_available]() { - this_update->update_info_.has_progress = false; - this_update->update_info_.progress = 0.0f; +defer: + // Release container before vTaskDelete (which doesn't call destructors) + container.reset(); + + // Defer to the main loop so all update_info_ and state_ writes happen on the + // same thread as readers (API, MQTT, web server). This is a single defer for + // both success and error paths to avoid multiple std::function instantiations. + // Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains. + this_update->defer([this_update, result]() { + if (result->error_str != nullptr) { + this_update->status_set_error(result->error_str); + delete result; + return; + } + + // Determine new state on main loop (avoids extra lambda captures from task) + bool trigger_update_available = false; + update::UpdateState new_state; + if (result->info.latest_version.empty() || result->info.latest_version == result->info.current_version) { + new_state = update::UPDATE_STATE_NO_UPDATE; + } else { + new_state = update::UPDATE_STATE_AVAILABLE; + if (this_update->state_ != update::UPDATE_STATE_AVAILABLE) { + trigger_update_available = true; + } + } + + this_update->update_info_ = std::move(result->info); + this_update->state_ = new_state; + delete result; // Safe: moved-from state is valid for destruction this_update->status_clear_error(); this_update->publish_state(); From 45be290392f902905c4b17b7bf3c998c0ce400fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Mar 2026 07:47:17 -1000 Subject: [PATCH 217/279] [ci] Bump Python to 3.14 in sync-device-classes workflow (#14912) --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index b0d966555be..a71e5ef4cad 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: 3.13 + python-version: "3.14" - name: Install Home Assistant run: | From e88c9ba0661131f39d5ee3c9de77a6e48204b4c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Mar 2026 07:47:42 -1000 Subject: [PATCH 218/279] [core] Inline progmem_read functions on non-ESP8266 platforms (#14913) --- esphome/components/esp32/core.cpp | 3 --- esphome/components/host/core.cpp | 3 --- esphome/components/libretiny/core.cpp | 3 --- esphome/components/rp2040/core.cpp | 7 ------- esphome/components/zephyr/core.cpp | 3 --- esphome/core/hal.h | 9 +++++++++ 6 files changed, 9 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index cba25bca2b2..83bd09b643d 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -53,9 +53,6 @@ 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() { uint32_t freq = 0; diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index d5c61ec986c..a662e842ee7 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -58,9 +58,6 @@ void HOT arch_feed_wdt() { // pass } -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; clock_gettime(CLOCK_MONOTONIC, &spec); diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 6bb2d9dcc14..1cfe68e9241 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -54,9 +54,6 @@ void arch_restart() { 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/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 7079cbca155..b7a90006123 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -37,13 +37,6 @@ void arch_init() { 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/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 1d105a10572..d7c77fdd2ca 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -59,9 +59,6 @@ void arch_feed_wdt() { 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() { auto *mutex = new k_mutex(); diff --git a/esphome/core/hal.h b/esphome/core/hal.h index c2c9b1a325e..03a30b7459f 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -41,8 +41,17 @@ void arch_init(); void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); + +#ifdef USE_ESP8266 +// ESP8266: pgm_read_* does real flash reads on Harvard architecture 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); +#else +// All other platforms: PROGMEM is a no-op, so these are direct dereferences +inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +inline const char *progmem_read_ptr(const char *const *addr) { return *addr; } +inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } +#endif } // namespace esphome From c9e6c85e6a66cee3dcc867e68b2ecd2589dbdc1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Mar 2026 07:48:11 -1000 Subject: [PATCH 219/279] [scheduler] Inline fast-path checks into header (#14905) --- esphome/core/scheduler.cpp | 69 +++++++++++++++++++++++----- esphome/core/scheduler.h | 92 ++++++++++++++------------------------ 2 files changed, 91 insertions(+), 70 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index db40ede78c6..44fc277ec82 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -454,6 +454,61 @@ void Scheduler::compact_defer_queue_locked_() { // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end()); } +void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { + // Process defer queue to guarantee FIFO execution order for deferred items. + // Previously, defer() used the heap which gave undefined order for equal timestamps, + // causing race conditions on multi-core systems (ESP32, BK7200). + // With the defer queue: + // - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_ + // - Items execute in exact order they were deferred (FIFO guarantee) + // - No deferred items exist in to_add_, so processing order doesn't affect correctness + // Single-core platforms don't use this queue and fall back to the heap-based approach. + // + // Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still + // processed here. They are skipped during execution by should_skip_item_(). + // This is intentional - no memory leak occurs. + // + // We use an index (defer_queue_front_) to track the read position instead of calling + // erase() on every pop, which would be O(n). The queue is processed once per loop - + // any items added during processing are left for the next loop iteration. + + // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), + // recycle each item after re-acquiring the lock for the next iteration (N+1 total). + // The lock is held across: recycle → loop condition → move-out, then released for execution. + SchedulerItem *item; + + this->lock_.lock(); + // Reset counter and snapshot queue end under lock + this->defer_count_clear_(); + size_t defer_queue_end = this->defer_queue_.size(); + if (this->defer_queue_front_ >= defer_queue_end) { + this->lock_.unlock(); + return; + } + while (this->defer_queue_front_ < defer_queue_end) { + // Take ownership of the item, leaving nullptr in the vector slot. + // This is safe because: + // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_) + // 3. The lock protects concurrent access, but the nullptr remains until cleanup + item = this->defer_queue_[this->defer_queue_front_]; + this->defer_queue_[this->defer_queue_front_] = nullptr; + this->defer_queue_front_++; + this->lock_.unlock(); + + // Execute callback without holding lock to prevent deadlocks + // if the callback tries to call defer() again + if (!this->should_skip_item_(item)) { + now = this->execute_item_(item, now); + } + + this->lock_.lock(); + this->recycle_item_main_loop_(item); + } + // Clean up the queue (lock already held from last recycle or initial acquisition) + this->cleanup_defer_queue_locked_(); + this->lock_.unlock(); +} #endif /* not ESPHOME_THREAD_SINGLE */ void HOT Scheduler::call(uint32_t now) { @@ -613,11 +668,7 @@ void HOT Scheduler::call(uint32_t now) { } #endif } -void HOT Scheduler::process_to_add() { - // Fast path: skip lock acquisition when nothing to add. - // Worst case is a one-loop-iteration delay before newly added items are processed. - if (this->to_add_empty_()) - return; +void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; for (auto *&it : this->to_add_) { if (is_item_removed_locked_(it)) { @@ -633,13 +684,7 @@ void HOT Scheduler::process_to_add() { this->to_add_.clear(); this->to_add_count_clear_(); } -bool HOT Scheduler::cleanup_() { - // Fast path: if nothing to remove, just check if items exist. - // Uses atomic load on platforms with atomics, falls back to always taking the lock otherwise. - // Worst case is a one-loop-iteration delay in cleanup. - if (this->to_remove_empty_()) - return !this->items_.empty(); - +bool HOT Scheduler::cleanup_slow_path_() { // We must hold the lock for the entire cleanup operation because: // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access // 2. We're decrementing to_remove_ which is also modified by other threads diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index e545055fcae..36c853ad176 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -131,7 +131,18 @@ class Scheduler { // @param now Fresh timestamp from millis() - must not be stale/cached void call(uint32_t now); - void process_to_add(); + // Move items from to_add_ into the main heap. + // IMPORTANT: This method should only be called from the main thread (loop task). + // Inlined: the fast path (nothing to add) is just an atomic load / empty check. + // The lock-free fast path uses to_add_count_ (atomic) or to_add_.empty() + // (single-threaded). This is safe because the main loop is the only thread + // that reads to_add_ without holding lock_; other threads may read it only + // while holding the mutex (e.g. cancel_item_locked_). + inline void HOT process_to_add() { + if (this->to_add_empty_()) + return; + this->process_to_add_slow_path_(); + } // Name storage type discriminator for SchedulerItem // Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs @@ -286,7 +297,20 @@ class Scheduler { // Cleanup logically deleted items from the scheduler // Returns true if items remain after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). - bool cleanup_(); + // Inlined: the fast path (nothing to remove) is just an atomic load + empty check. + // Reading items_.empty() without the lock is safe here because only the main + // loop thread structurally modifies items_ (push/pop/erase). Other threads may + // iterate items_ and mark items removed under lock_, but never change the + // vector's size or data pointer. + inline bool HOT cleanup_() { + if (this->to_remove_empty_()) + return !this->items_.empty(); + return this->cleanup_slow_path_(); + } + // Slow path for cleanup_() when there are items to remove - defined in scheduler.cpp + bool cleanup_slow_path_(); + // Slow path for process_to_add() when there are items to merge - defined in scheduler.cpp + void process_to_add_slow_path_(); // Remove and return the front item from the heap as a raw pointer. // Caller takes ownership and must either recycle or delete the item. // IMPORTANT: Caller must hold the scheduler lock before calling this function. @@ -376,68 +400,20 @@ class Scheduler { #endif /* ESPHOME_DEBUG_SCHEDULER */ #ifndef ESPHOME_THREAD_SINGLE - // Helper to process defer queue - inline for performance in hot path - inline void process_defer_queue_(uint32_t &now) { - // Process defer queue first to guarantee FIFO execution order for deferred items. - // Previously, defer() used the heap which gave undefined order for equal timestamps, - // causing race conditions on multi-core systems (ESP32, BK7200). - // With the defer queue: - // - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_ - // - Items execute in exact order they were deferred (FIFO guarantee) - // - No deferred items exist in to_add_, so processing order doesn't affect correctness - // Single-core platforms don't use this queue and fall back to the heap-based approach. - // - // Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still - // processed here. They are skipped during execution by should_skip_item_(). - // This is intentional - no memory leak occurs. - // - // We use an index (defer_queue_front_) to track the read position instead of calling - // erase() on every pop, which would be O(n). The queue is processed once per loop - - // any items added during processing are left for the next loop iteration. - + // Process defer queue for FIFO execution of deferred items. + // IMPORTANT: This method should only be called from the main thread (loop task). + // Inlined: the fast path (nothing deferred) is just an atomic load check. + inline void HOT process_defer_queue_(uint32_t &now) { // Fast path: nothing to process, avoid lock entirely. // Worst case is a one-loop-iteration delay before newly deferred items are processed. if (this->defer_empty_()) return; - - // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), - // recycle each item after re-acquiring the lock for the next iteration (N+1 total). - // The lock is held across: recycle → loop condition → move-out, then released for execution. - SchedulerItem *item; - - this->lock_.lock(); - // Reset counter and snapshot queue end under lock - this->defer_count_clear_(); - size_t defer_queue_end = this->defer_queue_.size(); - if (this->defer_queue_front_ >= defer_queue_end) { - this->lock_.unlock(); - return; - } - while (this->defer_queue_front_ < defer_queue_end) { - // Take ownership of the item, leaving nullptr in the vector slot. - // This is safe because: - // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_) - // 3. The lock protects concurrent access, but the nullptr remains until cleanup - item = this->defer_queue_[this->defer_queue_front_]; - this->defer_queue_[this->defer_queue_front_] = nullptr; - this->defer_queue_front_++; - this->lock_.unlock(); - - // Execute callback without holding lock to prevent deadlocks - // if the callback tries to call defer() again - if (!this->should_skip_item_(item)) { - now = this->execute_item_(item, now); - } - - this->lock_.lock(); - this->recycle_item_main_loop_(item); - } - // Clean up the queue (lock already held from last recycle or initial acquisition) - this->cleanup_defer_queue_locked_(); - this->lock_.unlock(); + this->process_defer_queue_slow_path_(now); } + // Slow path for process_defer_queue_() - defined in scheduler.cpp + void process_defer_queue_slow_path_(uint32_t &now); + // Helper to cleanup defer_queue_ after processing. // Keeps the common clear() path inline, outlines the rare compaction to keep // cold code out of the hot instruction cache lines. From 9a80c980cb91b783387cfd1552284e5f36d82ba9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Mar 2026 07:48:26 -1000 Subject: [PATCH 220/279] [scheduler] Early exit cancel path after first match (#14902) --- esphome/core/scheduler.cpp | 27 +++++++++++++++++------ esphome/core/scheduler.h | 21 +++++++++++++----- tests/benchmarks/core/bench_scheduler.cpp | 12 +++++++++- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 44fc277ec82..51cbfb208ea 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -138,7 +138,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, + /* find_first= */ true); } return; } @@ -209,7 +210,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Common epilogue: atomic cancel-and-add (unless skip_cancel is true) if (!skip_cancel) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, + /* find_first= */ true); } target->push_back(item); if (target == &this->to_add_) { @@ -723,13 +725,20 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) { LockGuard guard{this->lock_}; + // Public cancel path uses default find_first=false to cancel ALL matches because + // DelayAction parallel mode (skip_cancel=true) can create multiple items with the same key. return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry); } -// Helper to cancel items - must be called with lock held +// Helper to cancel matching items - must be called with lock held. +// When find_first=true, stops after the first match and exits across containers +// (used by set_timer_common_ where cancel-before-add guarantees at most one match). +// When find_first=false, cancels ALL matches across all containers (needed for +// public cancel path where DelayAction parallel mode can create duplicates). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) { + uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, + bool find_first) { // Early return if static string name is invalid if (name_type == NameType::STATIC_STRING && static_name == nullptr) { return false; @@ -741,7 +750,9 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Mark items in defer queue as cancelled (they'll be skipped when processed) if (type == SchedulerItem::TIMEOUT) { total_cancelled += this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name, - hash_or_id, type, match_retry); + hash_or_id, type, match_retry, find_first); + if (find_first && total_cancelled > 0) + return true; } #endif /* not ESPHOME_THREAD_SINGLE */ @@ -752,14 +763,16 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Only the main loop in call() should recycle items after execution completes. if (!this->items_.empty()) { size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, - hash_or_id, type, match_retry); + hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; this->to_remove_add_(heap_cancelled); + if (find_first && total_cancelled > 0) + return true; } // Cancel items in to_add_ total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name, - hash_or_id, type, match_retry); + hash_or_id, type, match_retry, find_first); return total_cancelled > 0; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 36c853ad176..1e44f41da84 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -320,10 +320,14 @@ class Scheduler { SchedulerItem *get_item_from_pool_locked_(); private: - // Helper to cancel items - must be called with lock held + // Helper to cancel matching items - must be called with lock held. + // When find_first=true, stops after the first match (used by set_timer_common_ where + // the cancel-before-add invariant guarantees at most one match). + // When find_first=false (default), cancels ALL matches (needed for DelayAction parallel + // mode where skip_cancel=true allows multiple items with the same key). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id bool cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false); + SchedulerItem::Type type, bool match_retry = false, bool find_first = false); // Common implementation for cancel operations - handles locking bool cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, @@ -483,18 +487,25 @@ class Scheduler { #endif } - // Helper to mark matching items in a container as removed + // Helper to mark matching items in a container as removed. + // When find_first=true, stops after the first match (used by set_timer_common_ where + // the cancel-before-add invariant guarantees at most one match). + // When find_first=false, marks ALL matches (needed for public cancel path where + // DelayAction parallel mode with skip_cancel=true can create multiple items with the same key). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id - // Returns the number of items marked for removal + // Returns the number of items marked for removal. // IMPORTANT: Must be called with scheduler lock held __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry) { + SchedulerItem::Type type, bool match_retry, + bool find_first = false) { size_t count = 0; for (auto *item : container) { if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { this->set_item_removed_(item, true); + if (find_first) + return 1; count++; } } diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 764f17ed735..9357734cc8e 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -99,11 +99,21 @@ BENCHMARK(Scheduler_SetTimeout); static void Scheduler_SetInterval(benchmark::State &state) { Scheduler scheduler; Component dummy_component; + // Number of distinct interval keys; controls how many unique timers exist + // simultaneously and the drain cadence for process_to_add(). + static constexpr int kKeyCount = 5; for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_interval(&dummy_component, static_cast(i % 5), 1000, []() {}); + scheduler.set_interval(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); + // Drain to_add_ periodically to reflect production behavior where + // process_to_add() runs each main loop iteration. Without this, + // cancelled items accumulate in to_add_ causing O(n²) scan cost. + if ((i + 1) % kKeyCount == 0) { + scheduler.process_to_add(); + } } + // Final drain in case kInnerIterations is not a multiple of 5 scheduler.process_to_add(); benchmark::DoNotOptimize(scheduler); } From 6cf32af33f4ec92d0b06939129be8c7cb254cb59 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:57:53 -0400 Subject: [PATCH 221/279] [seeed_mr24hpc1] Fix frame parser length handling bugs (#14863) --- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 263603704ac..c9fe3a2e6ef 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -297,19 +297,17 @@ void MR24HPC1Component::r24_split_data_frame_(uint8_t value) { this->sg_recv_data_state_ = FRAME_DATA_LEN_H; break; case FRAME_DATA_LEN_H: - if (value <= 4) { - this->sg_data_len_ = value * 256; + if (value == 0) { this->sg_frame_buf_[4] = value; this->sg_recv_data_state_ = FRAME_DATA_LEN_L; } else { - this->sg_data_len_ = 0; this->sg_recv_data_state_ = FRAME_IDLE; ESP_LOGD(TAG, "FRAME_DATA_LEN_H ERROR value:%x", value); } break; case FRAME_DATA_LEN_L: - this->sg_data_len_ += value; - if (this->sg_data_len_ > 32) { + this->sg_data_len_ = value; + if (this->sg_data_len_ == 0 || this->sg_data_len_ > 32) { ESP_LOGD(TAG, "len=%d, FRAME_DATA_LEN_L ERROR value:%x", this->sg_data_len_, value); this->sg_data_len_ = 0; this->sg_recv_data_state_ = FRAME_IDLE; @@ -320,9 +318,8 @@ void MR24HPC1Component::r24_split_data_frame_(uint8_t value) { } break; case FRAME_DATA_BYTES: - this->sg_data_len_ -= 1; this->sg_frame_buf_[this->sg_frame_len_++] = value; - if (this->sg_data_len_ <= 0) { + if (--this->sg_data_len_ == 0) { this->sg_recv_data_state_ = FRAME_DATA_CRC; } break; From 91e66cfd9d7e3a31ca4a0923463dcc107de321d1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:28:13 -0400 Subject: [PATCH 222/279] [gree] Fix IR checksum for YAA/YAC/YAC1FB9/GENERIC models (#14888) --- esphome/components/gree/gree.cpp | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 8a9f264932c..732ebd9632e 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -87,19 +87,12 @@ void GreeClimate::transmit_state() { // Calculate the checksum if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF) { remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0); - } else if (this->model_ == GREE_YAG) { + } else { remote_state[7] = ((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) + ((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) & 0x0F) << 4); - } else { - remote_state[7] = - ((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) + - ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + ((remote_state[7] & 0xF0) >> 4) + 0x0A) & - 0x0F) - << 4) | - (remote_state[7] & 0x0F); } auto transmit = this->transmitter_->transmit(); From 4cb93d4df8c3661158ffb7e96966eccdb08bedad Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:46:26 -0400 Subject: [PATCH 223/279] [sensor][ee895][hdc2010] Fix misc bugs found during component scan (#14890) --- esphome/components/ee895/ee895.cpp | 15 ++--- esphome/components/hdc2010/hdc2010.cpp | 76 +++++++++++--------------- esphome/components/sensor/__init__.py | 10 ++-- 3 files changed, 43 insertions(+), 58 deletions(-) diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 22f28be9bc3..93e5d4203b6 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -24,7 +24,7 @@ void EE895Component::setup() { this->read(serial_number, 20); crc16_check = (serial_number[19] << 8) + serial_number[18]; - if (crc16_check != calc_crc16_(serial_number, 19)) { + if (crc16_check != calc_crc16_(serial_number, 18)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); return; @@ -84,7 +84,7 @@ void EE895Component::write_command_(uint16_t addr, uint16_t reg_cnt) { address[2] = addr & 0xFF; address[3] = (reg_cnt >> 8) & 0xFF; address[4] = reg_cnt & 0xFF; - crc16 = calc_crc16_(address, 6); + crc16 = calc_crc16_(address, 5); address[5] = crc16 & 0xFF; address[6] = (crc16 >> 8) & 0xFF; this->write(address, 7); @@ -95,7 +95,7 @@ float EE895Component::read_float_() { uint8_t i2c_response[8]; this->read(i2c_response, 8); crc16_check = (i2c_response[7] << 8) + i2c_response[6]; - if (crc16_check != calc_crc16_(i2c_response, 7)) { + if (crc16_check != calc_crc16_(i2c_response, 6)) { this->error_code_ = CRC_CHECK_FAILED; this->status_set_warning(); return 0; @@ -107,12 +107,9 @@ float EE895Component::read_float_() { } uint16_t EE895Component::calc_crc16_(const uint8_t buf[], uint8_t len) { - uint8_t crc_check_buf[22]; - for (int i = 0; i < len; i++) { - crc_check_buf[i + 1] = buf[i]; - } - crc_check_buf[0] = this->address_; - return crc16(crc_check_buf, len); + uint8_t addr = this->address_; + uint16_t crc = crc16(&addr, 1); + return crc16(buf, len, crc); } } // namespace ee895 } // namespace esphome diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index c53fdb3f5be..0334b30eeca 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -7,50 +7,36 @@ namespace hdc2010 { static const char *const TAG = "hdc2010"; -static const uint8_t HDC2010_ADDRESS = 0x40; // 0b1000000 or 0b1000001 from datasheet -static const uint8_t HDC2010_CMD_CONFIGURATION_MEASUREMENT = 0x8F; -static const uint8_t HDC2010_CMD_START_MEASUREMENT = 0xF9; -static const uint8_t HDC2010_CMD_TEMPERATURE_LOW = 0x00; -static const uint8_t HDC2010_CMD_TEMPERATURE_HIGH = 0x01; -static const uint8_t HDC2010_CMD_HUMIDITY_LOW = 0x02; -static const uint8_t HDC2010_CMD_HUMIDITY_HIGH = 0x03; -static const uint8_t CONFIG = 0x0E; -static const uint8_t MEASUREMENT_CONFIG = 0x0F; +// Register addresses +static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; +static constexpr uint8_t REG_TEMPERATURE_HIGH = 0x01; +static constexpr uint8_t REG_HUMIDITY_LOW = 0x02; +static constexpr uint8_t REG_HUMIDITY_HIGH = 0x03; +static constexpr uint8_t REG_RESET_DRDY_INT_CONF = 0x0E; +static constexpr uint8_t REG_MEASUREMENT_CONF = 0x0F; + +// REG_MEASUREMENT_CONF (0x0F) bit masks +static constexpr uint8_t MEAS_TRIG = 0x01; // Bit 0: measurement trigger +static constexpr uint8_t MEAS_CONF_MASK = 0x06; // Bits 2:1: measurement mode +static constexpr uint8_t HRES_MASK = 0x30; // Bits 5:4: humidity resolution +static constexpr uint8_t TRES_MASK = 0xC0; // Bits 7:6: temperature resolution + +// REG_RESET_DRDY_INT_CONF (0x0E) bit masks +static constexpr uint8_t AMM_MASK = 0x70; // Bits 6:4: auto measurement mode void HDC2010Component::setup() { ESP_LOGCONFIG(TAG, "Running setup"); - const uint8_t data[2] = { - 0b00000000, // resolution 14bit for both humidity and temperature - 0b00000000 // reserved - }; - - if (!this->write_bytes(HDC2010_CMD_CONFIGURATION_MEASUREMENT, data, 2)) { - ESP_LOGW(TAG, "Initial config instruction error"); - this->status_set_warning(); - return; - } - - // Set measurement mode to temperature and humidity + // Set 14-bit resolution for both sensors and measurement mode to temp + humidity uint8_t config_contents; - this->read_register(MEASUREMENT_CONFIG, &config_contents, 1); - config_contents = (config_contents & 0xF9); // Always set to TEMP_AND_HUMID mode - this->write_bytes(MEASUREMENT_CONFIG, &config_contents, 1); + this->read_register(REG_MEASUREMENT_CONF, &config_contents, 1); + config_contents &= ~(TRES_MASK | HRES_MASK | MEAS_CONF_MASK); // 14-bit temp, 14-bit humidity, temp+humidity mode + this->write_bytes(REG_MEASUREMENT_CONF, &config_contents, 1); - // Set rate to manual - this->read_register(CONFIG, &config_contents, 1); - config_contents &= 0x8F; - this->write_bytes(CONFIG, &config_contents, 1); - - // Set temperature resolution to 14bit - this->read_register(CONFIG, &config_contents, 1); - config_contents &= 0x3F; - this->write_bytes(CONFIG, &config_contents, 1); - - // Set humidity resolution to 14bit - this->read_register(CONFIG, &config_contents, 1); - config_contents &= 0xCF; - this->write_bytes(CONFIG, &config_contents, 1); + // Set auto measurement rate to manual (on-demand via MEAS_TRIG) + this->read_register(REG_RESET_DRDY_INT_CONF, &config_contents, 1); + config_contents &= ~AMM_MASK; + this->write_bytes(REG_RESET_DRDY_INT_CONF, &config_contents, 1); } void HDC2010Component::dump_config() { @@ -67,9 +53,9 @@ void HDC2010Component::dump_config() { void HDC2010Component::update() { // Trigger measurement uint8_t config_contents; - this->read_register(CONFIG, &config_contents, 1); - config_contents |= 0x01; - this->write_bytes(MEASUREMENT_CONFIG, &config_contents, 1); + this->read_register(REG_MEASUREMENT_CONF, &config_contents, 1); + config_contents |= MEAS_TRIG; + this->write_bytes(REG_MEASUREMENT_CONF, &config_contents, 1); // 1ms delay after triggering the sample set_timeout(1, [this]() { @@ -90,8 +76,8 @@ void HDC2010Component::update() { float HDC2010Component::read_temp() { uint8_t byte[2]; - this->read_register(HDC2010_CMD_TEMPERATURE_LOW, &byte[0], 1); - this->read_register(HDC2010_CMD_TEMPERATURE_HIGH, &byte[1], 1); + this->read_register(REG_TEMPERATURE_LOW, &byte[0], 1); + this->read_register(REG_TEMPERATURE_HIGH, &byte[1], 1); uint16_t temp = encode_uint16(byte[1], byte[0]); return (float) temp * 0.0025177f - 40.0f; @@ -100,8 +86,8 @@ float HDC2010Component::read_temp() { float HDC2010Component::read_humidity() { uint8_t byte[2]; - this->read_register(HDC2010_CMD_HUMIDITY_LOW, &byte[0], 1); - this->read_register(HDC2010_CMD_HUMIDITY_HIGH, &byte[1], 1); + this->read_register(REG_HUMIDITY_LOW, &byte[0], 1); + this->read_register(REG_HUMIDITY_HIGH, &byte[1], 1); uint16_t humidity = encode_uint16(byte[1], byte[0]); return (float) humidity * 0.001525879f; diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 4be6ed1b841..02db381ff4c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -403,10 +403,12 @@ async def filter_out_filter_to_code(config, filter_id): QUANTILE_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, - cv.Optional(CONF_QUANTILE, default=0.9): cv.zero_to_one_float, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), + cv.Optional(CONF_QUANTILE, default=0.9): cv.float_range( + min=0, min_included=False, max=1 + ), } ), validate_send_first_at, From 98d3dce6727f39dd0a0efd4fdabf0daa9b369140 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:12:13 -0400 Subject: [PATCH 224/279] [voice_assistant][micro_wake_word] Fix null deref and missing error return (#14906) --- esphome/components/micro_wake_word/streaming_model.cpp | 1 + esphome/components/voice_assistant/voice_assistant.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 47d2c70e138..0ab6cd3772d 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -80,6 +80,7 @@ bool StreamingModel::load_model_() { TfLiteTensor *output = this->interpreter_->output(0); if ((output->dims->size != 2) || (output->dims->data[0] != 1) || (output->dims->data[1] != 1)) { ESP_LOGE(TAG, "Streaming model tensor output dimension is not 1x1."); + return false; } if (output->type != kTfLiteUInt8) { diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 51d52a8af8d..15124e422ff 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -619,6 +619,8 @@ void VoiceAssistant::start_playback_timeout_() { this->cancel_timeout("speaker-timeout"); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); + if (this->api_client_ == nullptr) + return; api::VoiceAssistantAnnounceFinished msg; msg.success = true; this->api_client_->send_message(msg); From fc67551edc0bfc4e5966e11b325c2d5277114df5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:48:43 -0400 Subject: [PATCH 225/279] [tc74][apds9960] Fix signed temperature and FIFO register address (#14907) --- esphome/components/apds9960/apds9960.cpp | 8 ++++---- esphome/components/tc74/tc74.cpp | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index 260de82d143..a07175f2c9c 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -251,11 +251,11 @@ void APDS9960::read_gesture_data_() { uint8_t buf[128]; for (uint8_t pos = 0; pos < fifo_level * 4; pos += 32) { - // The ESP's i2c driver has a limited buffer size. - // This way of retrieving the data should be wrong according to the datasheet - // but it seems to work. + // Read in 32-byte chunks due to ESP8266 I2C buffer limit. + // Always read from 0xFC — the FIFO auto-increments through 0xFC-0xFF + // and advances its internal pointer after every 4th byte. uint8_t read = std::min(32, fifo_level * 4 - pos); - APDS9960_WARNING_CHECK(this->read_bytes(0xFC + pos, buf + pos, read), "Reading FIFO buffer failed."); + APDS9960_WARNING_CHECK(this->read_bytes(0xFC, buf + pos, read), "Reading FIFO buffer failed."); } if (millis() - this->gesture_start_ > 500) { diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index 969ef3671e2..cb58e583dca 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -50,8 +50,9 @@ void TC74Component::read_temperature_() { } } - uint8_t temperature_reg; - if (this->read_register(TC74_REGISTER_TEMPERATURE, &temperature_reg, 1) != i2c::ERROR_OK) { + int8_t temperature_reg; + if (this->read_register(TC74_REGISTER_TEMPERATURE, reinterpret_cast(&temperature_reg), 1) != + i2c::ERROR_OK) { this->status_set_warning(); return; } From 448402ca2cb49160c66472e3464dafe2209ab47b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Mar 2026 07:46:18 -1000 Subject: [PATCH 226/279] [http_request] Fix data race on update_info_ strings in update task (#14909) --- .../update/http_request_update.cpp | 222 ++++++++++-------- 1 file changed, 121 insertions(+), 101 deletions(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c40590af955..a15dc616753 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -23,6 +23,12 @@ namespace http_request { static const char *const TAG = "http_request.update"; +// Wraps UpdateInfo + error for the task→main-loop handoff. +struct TaskResult { + update::UpdateInfo info; + const LogString *error_str{nullptr}; +}; + static const size_t MAX_READ_SIZE = 256; static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000; @@ -77,134 +83,148 @@ void HttpRequestUpdate::update() { void HttpRequestUpdate::update_task(void *params) { HttpRequestUpdate *this_update = (HttpRequestUpdate *) params; + // Allocate once — every path below returns via the single defer at the end. + // On failure, error_str is set; on success it is nullptr. + auto *result = new TaskResult(); + auto *info = &result->info; + auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to fetch manifest")); }); - UPDATE_RETURN; + if (container != nullptr) + container->end(); + result->error_str = LOG_STR("Failed to fetch manifest"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } - RAMAllocator allocator; - uint8_t *data = allocator.allocate(container->content_length); - if (data == nullptr) { - ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer( - [this_update]() { this_update->status_set_error(LOG_STR("Failed to allocate memory for manifest")); }); - container->end(); - UPDATE_RETURN; - } - - auto read_result = http_read_fully(container.get(), data, container->content_length, MAX_READ_SIZE, - this_update->request_parent_->get_timeout()); - if (read_result.status != HttpReadStatus::OK) { - if (read_result.status == HttpReadStatus::TIMEOUT) { - ESP_LOGE(TAG, "Timeout reading manifest"); - } else { - ESP_LOGE(TAG, "Error reading manifest: %d", read_result.error_code); + { + RAMAllocator allocator; + uint8_t *data = allocator.allocate(container->content_length); + if (data == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); + container->end(); + result->error_str = LOG_STR("Failed to allocate memory for manifest"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to read manifest")); }); - allocator.deallocate(data, container->content_length); - container->end(); - UPDATE_RETURN; - } - size_t read_index = container->get_bytes_read(); - size_t content_length = container->content_length; - container->end(); - container.reset(); // Release ownership of the container's shared_ptr - - bool valid = false; - { // Scope to ensure JsonDocument is destroyed before deallocating buffer - valid = json::parse_json(data, read_index, [this_update](JsonObject root) -> bool { - if (!root[ESPHOME_F("name")].is() || !root[ESPHOME_F("version")].is() || - !root[ESPHOME_F("builds")].is()) { - ESP_LOGE(TAG, "Manifest does not contain required fields"); - return false; + auto read_result = http_read_fully(container.get(), data, container->content_length, MAX_READ_SIZE, + this_update->request_parent_->get_timeout()); + if (read_result.status != HttpReadStatus::OK) { + if (read_result.status == HttpReadStatus::TIMEOUT) { + ESP_LOGE(TAG, "Timeout reading manifest"); + } else { + ESP_LOGE(TAG, "Error reading manifest: %d", read_result.error_code); } - this_update->update_info_.title = root[ESPHOME_F("name")].as(); - this_update->update_info_.latest_version = root[ESPHOME_F("version")].as(); + allocator.deallocate(data, container->content_length); + container->end(); + result->error_str = LOG_STR("Failed to read manifest"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) + } + size_t read_index = container->get_bytes_read(); + size_t content_length = container->content_length; - auto builds_array = root[ESPHOME_F("builds")].as(); - for (auto build : builds_array) { - if (!build[ESPHOME_F("chipFamily")].is()) { + container->end(); + container.reset(); // Release ownership of the container's shared_ptr + + bool valid = false; + { // Scope to ensure JsonDocument is destroyed before deallocating buffer + valid = json::parse_json(data, read_index, [info](JsonObject root) -> bool { + if (!root[ESPHOME_F("name")].is() || !root[ESPHOME_F("version")].is() || + !root[ESPHOME_F("builds")].is()) { ESP_LOGE(TAG, "Manifest does not contain required fields"); return false; } - if (build[ESPHOME_F("chipFamily")] == ESPHOME_VARIANT) { - if (!build[ESPHOME_F("ota")].is()) { + info->title = root[ESPHOME_F("name")].as(); + info->latest_version = root[ESPHOME_F("version")].as(); + + auto builds_array = root[ESPHOME_F("builds")].as(); + for (auto build : builds_array) { + if (!build[ESPHOME_F("chipFamily")].is()) { ESP_LOGE(TAG, "Manifest does not contain required fields"); return false; } - JsonObject ota = build[ESPHOME_F("ota")].as(); - if (!ota[ESPHOME_F("path")].is() || !ota[ESPHOME_F("md5")].is()) { - ESP_LOGE(TAG, "Manifest does not contain required fields"); - return false; + if (build[ESPHOME_F("chipFamily")] == ESPHOME_VARIANT) { + if (!build[ESPHOME_F("ota")].is()) { + ESP_LOGE(TAG, "Manifest does not contain required fields"); + return false; + } + JsonObject ota = build[ESPHOME_F("ota")].as(); + if (!ota[ESPHOME_F("path")].is() || !ota[ESPHOME_F("md5")].is()) { + ESP_LOGE(TAG, "Manifest does not contain required fields"); + return false; + } + info->firmware_url = ota[ESPHOME_F("path")].as(); + info->md5 = ota[ESPHOME_F("md5")].as(); + + if (ota[ESPHOME_F("summary")].is()) + info->summary = ota[ESPHOME_F("summary")].as(); + if (ota[ESPHOME_F("release_url")].is()) + info->release_url = ota[ESPHOME_F("release_url")].as(); + + return true; } - this_update->update_info_.firmware_url = ota[ESPHOME_F("path")].as(); - this_update->update_info_.md5 = ota[ESPHOME_F("md5")].as(); - - if (ota[ESPHOME_F("summary")].is()) - this_update->update_info_.summary = ota[ESPHOME_F("summary")].as(); - if (ota[ESPHOME_F("release_url")].is()) - this_update->update_info_.release_url = ota[ESPHOME_F("release_url")].as(); - - return true; } - } - return false; - }); - } - allocator.deallocate(data, content_length); + return false; + }); + } + allocator.deallocate(data, content_length); - if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); - // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to parse manifest JSON")); }); - UPDATE_RETURN; - } + if (!valid) { + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + result->error_str = LOG_STR("Failed to parse manifest JSON"); + goto defer; // NOLINT(cppcoreguidelines-avoid-goto) + } - // Merge source_url_ and this_update->update_info_.firmware_url - if (this_update->update_info_.firmware_url.find("http") == std::string::npos) { - std::string path = this_update->update_info_.firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - this_update->update_info_.firmware_url = domain + path; - } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - this_update->update_info_.firmware_url = domain + path; + // Merge source_url_ and firmware_url + if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { + std::string path = info->firmware_url; + if (path[0] == '/') { + std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); + info->firmware_url = domain + path; + } else { + std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); + info->firmware_url = domain + path; + } } - } #ifdef ESPHOME_PROJECT_VERSION - this_update->update_info_.current_version = ESPHOME_PROJECT_VERSION; + info->current_version = ESPHOME_PROJECT_VERSION; #else - this_update->update_info_.current_version = ESPHOME_VERSION; + info->current_version = ESPHOME_VERSION; #endif - - bool trigger_update_available = false; - - if (this_update->update_info_.latest_version.empty() || - this_update->update_info_.latest_version == this_update->update_info_.current_version) { - this_update->state_ = update::UPDATE_STATE_NO_UPDATE; - } else { - if (this_update->state_ != update::UPDATE_STATE_AVAILABLE) { - trigger_update_available = true; - } - this_update->state_ = update::UPDATE_STATE_AVAILABLE; } - // Defer to main loop to ensure thread-safe execution of: - // - status_clear_error() performs non-atomic read-modify-write on component_state_ - // - publish_state() triggers API callbacks that write to the shared protobuf buffer - // which can be corrupted if accessed concurrently from task and main loop threads - // - update_available trigger to ensure consistent state when the trigger fires - this_update->defer([this_update, trigger_update_available]() { - this_update->update_info_.has_progress = false; - this_update->update_info_.progress = 0.0f; +defer: + // Release container before vTaskDelete (which doesn't call destructors) + container.reset(); + + // Defer to the main loop so all update_info_ and state_ writes happen on the + // same thread as readers (API, MQTT, web server). This is a single defer for + // both success and error paths to avoid multiple std::function instantiations. + // Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains. + this_update->defer([this_update, result]() { + if (result->error_str != nullptr) { + this_update->status_set_error(result->error_str); + delete result; + return; + } + + // Determine new state on main loop (avoids extra lambda captures from task) + bool trigger_update_available = false; + update::UpdateState new_state; + if (result->info.latest_version.empty() || result->info.latest_version == result->info.current_version) { + new_state = update::UPDATE_STATE_NO_UPDATE; + } else { + new_state = update::UPDATE_STATE_AVAILABLE; + if (this_update->state_ != update::UPDATE_STATE_AVAILABLE) { + trigger_update_available = true; + } + } + + this_update->update_info_ = std::move(result->info); + this_update->state_ = new_state; + delete result; // Safe: moved-from state is valid for destruction this_update->status_clear_error(); this_update->publish_state(); From af9366fdd4822cb5e8a1a7c571c08d6901993cc3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:19:26 +1300 Subject: [PATCH 227/279] Bump version to 2026.3.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ea34106f36a..53eae489669 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.3.0b4 +PROJECT_NUMBER = 2026.3.0b5 # 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 4ba8b1a23fc..579235ff69a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0b4" +__version__ = "2026.3.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 89066e3e20c84c1d2eac1a7ebd54059381c9a253 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:33:00 -1000 Subject: [PATCH 228/279] Bump actions/cache from 5.0.3 to 5.0.4 (#14929) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a03579abc5..cf5c7029c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv # yamllint disable-line rule:line-length @@ -159,7 +159,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -198,7 +198,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -231,7 +231,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -253,7 +253,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -387,14 +387,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -466,14 +466,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -555,14 +555,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -817,7 +817,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -841,7 +841,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -882,7 +882,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -929,7 +929,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 3a47317fc890e04d2148edcb567beac8de6d8268 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:33:15 -1000 Subject: [PATCH 229/279] Bump actions/cache from 5.0.3 to 5.0.4 in /.github/actions/restore-python (#14930) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6d7d4f8c129..af54175c01e 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv # yamllint disable-line rule:line-length From ef3afe3e2183d01d34d1910ef8aae22a7f36fd8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:33:29 -1000 Subject: [PATCH 230/279] Bump codecov/codecov-action from 5.5.2 to 5.5.3 (#14928) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf5c7029c5b..ead87ad0874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 16667bf5be6c17df7125d01bff3acac87a3fd8c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:39:26 -1000 Subject: [PATCH 231/279] Bump aioesphomeapi from 44.5.2 to 44.6.0 (#14927) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index da95dd5a13d..f8f60f19329 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.5.2 +aioesphomeapi==44.6.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 47909d529982a7e8ac400750b3237ac689822c47 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:47:14 -0400 Subject: [PATCH 232/279] [hub75] Bump esp-hub75 to 0.3.5 (#14915) --- esphome/components/hub75/display.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 4cca0cea5da..ede5078c33b 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -587,7 +587,7 @@ def _build_config_struct( async def to_code(config: ConfigType) -> None: add_idf_component( name="esphome/esp-hub75", - ref="0.3.4", + ref="0.3.5", ) # Set compile-time configuration via build flags (so external library sees them) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index a847e34b022..620dc6131dd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -64,7 +64,7 @@ dependencies: rules: - if: "target in [esp32s2, esp32s3, esp32p4]" esphome/esp-hub75: - version: 0.3.4 + version: 0.3.5 rules: - if: "target in [esp32, esp32s2, esp32s3, esp32c6, esp32p4]" espressif/mqtt: From cc0655a9048202ccd62fcca881b2fb6fc3a1b12d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:42:13 -0400 Subject: [PATCH 233/279] [bedjet][light][i2s_audio][ld2412] Fix uninitialized pointers, div-by-zero, and buffer validation (#14925) --- esphome/components/bedjet/bedjet_codec.h | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 2 +- esphome/components/ld2412/ld2412.cpp | 41 +++++++------------ esphome/components/light/effects.py | 2 +- 4 files changed, 18 insertions(+), 29 deletions(-) diff --git a/esphome/components/bedjet/bedjet_codec.h b/esphome/components/bedjet/bedjet_codec.h index 07aee32d540..3936ba23151 100644 --- a/esphome/components/bedjet/bedjet_codec.h +++ b/esphome/components/bedjet/bedjet_codec.h @@ -183,7 +183,7 @@ class BedjetCodec { BedjetPacket packet_; - BedjetStatusPacket *status_packet_; + BedjetStatusPacket *status_packet_{nullptr}; BedjetStatusPacket buf_; }; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 1d03a4c4951..93ec754178e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -110,7 +110,7 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp TaskHandle_t speaker_task_handle_{nullptr}; EventGroupHandle_t event_group_{nullptr}; - QueueHandle_t i2s_event_queue_; + QueueHandle_t i2s_event_queue_{nullptr}; std::weak_ptr audio_ring_buffer_; diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 37578dd8daf..6ff6963e9f7 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -455,12 +455,10 @@ void LD2412Component::handle_periodic_data_() { } #ifdef USE_NUMBER -std::function set_number_value(number::Number *n, float value) { +void set_number_value(number::Number *n, float value) { if (n != nullptr && (!n->has_state() || n->state != value)) { - n->state = value; - return [n, value]() { n->publish_state(value); }; + n->publish_state(value); } - return []() {}; } #endif @@ -504,6 +502,9 @@ bool LD2412Component::handle_ack_data_() { break; case CMD_QUERY_VERSION: { + if (this->buffer_pos_ < 12 + sizeof(this->version_)) { + return false; + } std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); char version_s[20]; ld24xx::format_version_str(this->version_, version_s); @@ -596,13 +597,8 @@ bool LD2412Component::handle_ack_data_() { case CMD_QUERY_MOTION_GATE_SENS: { #ifdef USE_NUMBER - std::vector> updates; - updates.reserve(this->gate_still_threshold_numbers_.size()); - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { - updates.push_back(set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i])); - } - for (auto &update : updates) { - update(); + for (size_t i = 0; i < this->gate_move_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { + set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i]); } #endif break; @@ -610,13 +606,8 @@ bool LD2412Component::handle_ack_data_() { case CMD_QUERY_STATIC_GATE_SENS: { #ifdef USE_NUMBER - std::vector> updates; - updates.reserve(this->gate_still_threshold_numbers_.size()); - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { - updates.push_back(set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i])); - } - for (auto &update : updates) { - update(); + for (size_t i = 0; i < this->gate_still_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { + set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i]); } #endif break; @@ -625,20 +616,21 @@ bool LD2412Component::handle_ack_data_() { case CMD_QUERY_BASIC_CONF: // Query parameters response { #ifdef USE_NUMBER + if (this->buffer_pos_ < 15) { + return false; + } /* Moving distance range: 9th byte Still distance range: 10th byte */ - std::vector> updates; - updates.push_back(set_number_value(this->min_distance_gate_number_, this->buffer_data_[10])); - updates.push_back(set_number_value(this->max_distance_gate_number_, this->buffer_data_[11] - 1)); + set_number_value(this->min_distance_gate_number_, this->buffer_data_[10]); + set_number_value(this->max_distance_gate_number_, this->buffer_data_[11] - 1); ESP_LOGV(TAG, "min_distance_gate_number_: %u, max_distance_gate_number_ %u", this->buffer_data_[10], this->buffer_data_[11]); /* None Duration: 11~12th bytes */ - updates.push_back( - set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[13], this->buffer_data_[12]))); + set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[13], this->buffer_data_[12])); ESP_LOGV(TAG, "timeout_number_: %u", encode_uint16(this->buffer_data_[13], this->buffer_data_[12])); /* Output pin configuration: 13th bytes @@ -650,9 +642,6 @@ bool LD2412Component::handle_ack_data_() { this->out_pin_level_select_->publish_state(out_pin_level_str); } #endif - for (auto &update : updates) { - update(); - } #endif } break; default: diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 15d9272d1a1..4088a78e0d2 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -392,7 +392,7 @@ async def addressable_lambda_effect_to_code(config, effect_id): "Rainbow", { cv.Optional(CONF_SPEED, default=10): cv.uint32_t, - cv.Optional(CONF_WIDTH, default=50): cv.uint32_t, + cv.Optional(CONF_WIDTH, default=50): cv.int_range(min=1, max=65535), }, ) async def addressable_rainbow_effect_to_code(config, effect_id): From 4a93d5b54465647a8abfa226e2091650d9057b4c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:42:53 -0400 Subject: [PATCH 234/279] [vl53l0x][ld2420][ble_client][inkplate] Fix state corruption, crash, OOB read, and shift UB (#14919) --- .../ble_client/sensor/ble_sensor.cpp | 8 +++++++- .../text_sensor/ble_text_sensor.cpp | 4 ++++ esphome/components/inkplate/inkplate.cpp | 2 +- esphome/components/inkplate/inkplate.h | 20 +++++++++---------- esphome/components/ld2420/ld2420.cpp | 20 +++++++++++-------- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 1 + 6 files changed, 35 insertions(+), 20 deletions(-) diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index fe5f11bbc2b..4bd871dc817 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -102,6 +102,10 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga break; } case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.value_len == 0) { + ESP_LOGW(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: empty value", this->get_name().c_str()); + break; + } ESP_LOGD(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: handle=0x%x, value=0x%x", this->get_name().c_str(), param->notify.handle, param->notify.value[0]); if (param->notify.handle != this->handle) @@ -131,8 +135,10 @@ float BLESensor::parse_data_(uint8_t *value, uint16_t value_len) { if (this->has_data_to_value_) { std::vector data(value, value + value_len); return this->data_to_value_func_(data); - } else { + } else if (value_len > 0) { return value[0]; + } else { + return NAN; } } diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index cacf1b48357..7eaa6af0761 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -104,6 +104,10 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_NOTIFY_EVT: { if (param->notify.handle != this->handle) break; + if (param->notify.value_len == 0) { + ESP_LOGW(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: empty value", this->get_name().c_str()); + break; + } ESP_LOGV(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: handle=0x%x, value=0x%x", this->get_name().c_str(), param->notify.handle, param->notify.value[0]); this->publish_state(reinterpret_cast(param->notify.value), param->notify.value_len); diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 7551c6fc775..326bdff774e 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -229,7 +229,7 @@ void Inkplate::eink_off_() { this->oe_pin_->digital_write(false); this->gmod_pin_->digital_write(false); - GPIO.out &= ~(this->get_data_pin_mask_() | (1 << this->cl_pin_->get_pin()) | (1 << this->le_pin_->get_pin())); + GPIO.out &= ~(this->get_data_pin_mask_() | (1UL << this->cl_pin_->get_pin()) | (1UL << this->le_pin_->get_pin())); this->ckv_pin_->digital_write(false); this->sph_pin_->digital_write(false); this->spv_pin_->digital_write(false); diff --git a/esphome/components/inkplate/inkplate.h b/esphome/components/inkplate/inkplate.h index fb4674b522d..bcd56b829a0 100644 --- a/esphome/components/inkplate/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -152,16 +152,16 @@ class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { size_t get_buffer_length_(); - int get_data_pin_mask_() { - int data = 0; - data |= (1 << this->display_data_0_pin_->get_pin()); - data |= (1 << this->display_data_1_pin_->get_pin()); - data |= (1 << this->display_data_2_pin_->get_pin()); - data |= (1 << this->display_data_3_pin_->get_pin()); - data |= (1 << this->display_data_4_pin_->get_pin()); - data |= (1 << this->display_data_5_pin_->get_pin()); - data |= (1 << this->display_data_6_pin_->get_pin()); - data |= (1 << this->display_data_7_pin_->get_pin()); + uint32_t get_data_pin_mask_() { + uint32_t data = 0; + data |= (1UL << this->display_data_0_pin_->get_pin()); + data |= (1UL << this->display_data_1_pin_->get_pin()); + data |= (1UL << this->display_data_2_pin_->get_pin()); + data |= (1UL << this->display_data_3_pin_->get_pin()); + data |= (1UL << this->display_data_4_pin_->get_pin()); + data |= (1UL << this->display_data_5_pin_->get_pin()); + data |= (1UL << this->display_data_6_pin_->get_pin()); + data |= (1UL << this->display_data_7_pin_->get_pin()); return data; } diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index 1e671363c9d..ae622cda283 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -170,14 +170,18 @@ static uint8_t calc_checksum(void *data, size_t size) { return checksum; } -static int get_firmware_int(const char *version_string) { - std::string version_str = version_string; - if (version_str[0] == 'v') { - version_str.erase(0, 1); +static int32_t get_firmware_int(const char *version_string) { + // Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits + const char *p = (*version_string == 'v') ? version_string + 1 : version_string; + int32_t result = 0; + for (; *p != '\0'; p++) { + if (*p == '.') + continue; + if (*p < '0' || *p > '9') + return 0; + result = result * 10 + (*p - '0'); } - version_str.erase(remove(version_str.begin(), version_str.end(), '.'), version_str.end()); - int version_integer = stoi(version_str); - return version_integer; + return result; } float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } @@ -683,7 +687,7 @@ int LD2420Component::send_cmd_from_array(CmdFrameT frame) { retry = 0; } if (this->cmd_reply_.error > 0) { - this->handle_cmd_error(error); + this->handle_cmd_error(this->cmd_reply_.error); } } return error; diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 0b2b40d723a..8a76ed77605 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -266,6 +266,7 @@ void VL53L0XSensor::update() { this->status_momentary_warning("update", 5000); ESP_LOGW(TAG, "%s - update called before prior reading complete - initiated:%d waiting_for_interrupt:%d", this->name_.c_str(), this->initiated_read_, this->waiting_for_interrupt_); + return; } // initiate single shot measurement From 73a49493a261a2a7fb748875e66d85bf0ff2b050 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:43:42 -0400 Subject: [PATCH 235/279] [vbus][shelly_dimmer][st7789v][modbus_controller] Fix integer overflows, off-by-one, and coordinate swap (#14916) --- .../modbus_controller/modbus_controller.h | 2 +- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- esphome/components/st7789v/st7789v.cpp | 4 +-- .../components/vbus/sensor/vbus_sensor.cpp | 26 ++++++++++++------- esphome/components/vbus/vbus.cpp | 3 +-- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index fca29265682..bd3d4d705ec 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -178,7 +178,7 @@ template N mask_and_shift_by_rightbit(N data, uint32_t mask) { return result; } for (size_t pos = 0; pos < sizeof(N) << 3; pos++) { - if ((mask & (1 << pos)) != 0) + if ((mask & (1UL << pos)) != 0) return result >> pos; } return 0; diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index 88fcbcbfe15..230fb963b13 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -402,7 +402,7 @@ bool ShellyDimmer::handle_frame_() { // Handle response. switch (cmd) { case SHELLY_DIMMER_PROTO_CMD_POLL: { - if (payload_len < 16) { + if (payload_len < 17) { return false; } diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index 6e4360ae742..dc03fb04cae 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -156,9 +156,9 @@ void ST7789V::update() { void ST7789V::set_model_str(const char *model_str) { this->model_str_ = model_str; } void ST7789V::write_display_data() { - uint16_t x1 = this->offset_height_; + uint16_t x1 = this->offset_width_; uint16_t x2 = x1 + get_width_internal() - 1; - uint16_t y1 = this->offset_width_; + uint16_t y1 = this->offset_height_; uint16_t y2 = y1 + get_height_internal() - 1; this->enable(); diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 1cabb49703b..407a81c83b1 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -48,8 +48,8 @@ void DeltaSolBSPlusSensor::handle_message(std::vector &message) { if (this->operating_hours2_sensor_ != nullptr) this->operating_hours2_sensor_->publish_state(get_u16(message, 18)); if (this->heat_quantity_sensor_ != nullptr) { - this->heat_quantity_sensor_->publish_state(get_u16(message, 20) + get_u16(message, 22) * 1000 + - get_u16(message, 24) * 1000000); + this->heat_quantity_sensor_->publish_state(get_u16(message, 20) + get_u16(message, 22) * 1000.0f + + get_u16(message, 24) * 1000000.0f); } if (this->time_sensor_ != nullptr) this->time_sensor_->publish_state(get_u16(message, 12)); @@ -130,8 +130,8 @@ void DeltaSolCSensor::handle_message(std::vector &message) { if (this->operating_hours2_sensor_ != nullptr) this->operating_hours2_sensor_->publish_state(get_u16(message, 14)); if (this->heat_quantity_sensor_ != nullptr) { - this->heat_quantity_sensor_->publish_state(get_u16(message, 16) + get_u16(message, 18) * 1000 + - get_u16(message, 20) * 1000000); + this->heat_quantity_sensor_->publish_state(get_u16(message, 16) + get_u16(message, 18) * 1000.0f + + get_u16(message, 20) * 1000000.0f); } if (this->time_sensor_ != nullptr) this->time_sensor_->publish_state(get_u16(message, 22)); @@ -162,8 +162,10 @@ void DeltaSolCS2Sensor::handle_message(std::vector &message) { this->pump_speed_sensor_->publish_state(message[12]); if (this->operating_hours_sensor_ != nullptr) this->operating_hours_sensor_->publish_state(get_u16(message, 14)); - if (this->heat_quantity_sensor_ != nullptr) - this->heat_quantity_sensor_->publish_state((get_u16(message, 26) << 16) + get_u16(message, 24)); + if (this->heat_quantity_sensor_ != nullptr) { + this->heat_quantity_sensor_->publish_state((static_cast(get_u16(message, 26)) << 16) | + get_u16(message, 24)); + } if (this->version_sensor_ != nullptr) this->version_sensor_->publish_state(get_u16(message, 28) * 0.01f); } @@ -204,8 +206,10 @@ void DeltaSolCS4Sensor::handle_message(std::vector &message) { this->operating_hours1_sensor_->publish_state(get_u16(message, 10)); if (this->operating_hours2_sensor_ != nullptr) this->operating_hours2_sensor_->publish_state(get_u16(message, 14)); - if (this->heat_quantity_sensor_ != nullptr) - this->heat_quantity_sensor_->publish_state((get_u16(message, 30) << 16) + get_u16(message, 28)); + if (this->heat_quantity_sensor_ != nullptr) { + this->heat_quantity_sensor_->publish_state((static_cast(get_u16(message, 30)) << 16) | + get_u16(message, 28)); + } if (this->time_sensor_ != nullptr) this->time_sensor_->publish_state(get_u16(message, 22)); if (this->version_sensor_ != nullptr) @@ -250,8 +254,10 @@ void DeltaSolCSPlusSensor::handle_message(std::vector &message) { this->operating_hours1_sensor_->publish_state(get_u16(message, 10)); if (this->operating_hours2_sensor_ != nullptr) this->operating_hours2_sensor_->publish_state(get_u16(message, 14)); - if (this->heat_quantity_sensor_ != nullptr) - this->heat_quantity_sensor_->publish_state((get_u16(message, 30) << 16) + get_u16(message, 28)); + if (this->heat_quantity_sensor_ != nullptr) { + this->heat_quantity_sensor_->publish_state((static_cast(get_u16(message, 30)) << 16) | + get_u16(message, 28)); + } if (this->time_sensor_ != nullptr) this->time_sensor_->publish_state(get_u16(message, 22)); if (this->version_sensor_ != nullptr) diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index c6786ee31e8..195d6ed5680 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -67,8 +67,7 @@ void VBus::loop() { } septet_spread(this->buffer_.data(), 7, 6, this->buffer_[13]); uint16_t id = (this->buffer_[8] << 8) + this->buffer_[7]; - uint32_t value = - (this->buffer_[12] << 24) + (this->buffer_[11] << 16) + (this->buffer_[10] << 8) + this->buffer_[9]; + uint32_t value = encode_uint32(this->buffer_[12], this->buffer_[11], this->buffer_[10], this->buffer_[9]); ESP_LOGV(TAG, "P1 C%04x %04x->%04x: %04x %04" PRIx32 " (%" PRIu32 ")", this->command_, this->source_, this->dest_, id, value, value); } else if ((this->protocol_ == 0x10) && (this->buffer_.size() == 9)) { From 097e6eb41fba8fede3495e63ae0357880b26bd9d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:42:56 -0400 Subject: [PATCH 236/279] [i2s_audio] Remove legacy I2S driver support (#14932) --- CODEOWNERS | 1 - esphome/components/i2s_audio/__init__.py | 101 ++----- esphome/components/i2s_audio/i2s_audio.h | 35 --- .../i2s_audio/media_player/__init__.py | 122 +------- .../media_player/i2s_audio_media_player.cpp | 260 ------------------ .../media_player/i2s_audio_media_player.h | 87 ------ .../i2s_audio/microphone/__init__.py | 20 +- .../microphone/i2s_audio_microphone.cpp | 97 +------ .../microphone/i2s_audio_microphone.h | 21 -- .../components/i2s_audio/speaker/__init__.py | 36 +-- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 126 --------- .../i2s_audio/speaker/i2s_audio_speaker.h | 18 -- esphome/core/defines.h | 1 - .../components/i2s_audio/test.esp32-ard.yaml | 16 -- tests/components/media_player/common.yaml | 16 +- 15 files changed, 50 insertions(+), 907 deletions(-) delete mode 100644 esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp delete mode 100644 esphome/components/i2s_audio/media_player/i2s_audio_media_player.h delete mode 100644 tests/components/i2s_audio/test.esp32-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index e72b1647614..88f62c3194e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -244,7 +244,6 @@ esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core esphome/components/i2c_device/* @gabest11 esphome/components/i2s_audio/* @jesserockz -esphome/components/i2s_audio/media_player/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 977a239497a..ffa63f5ee8e 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -53,8 +53,6 @@ CONF_RIGHT = "right" CONF_STEREO = "stereo" CONF_BOTH = "both" -CONF_USE_LEGACY = "use_legacy" - i2s_audio_ns = cg.esphome_ns.namespace("i2s_audio") I2SAudioComponent = i2s_audio_ns.class_("I2SAudioComponent", cg.Component) I2SAudioBase = i2s_audio_ns.class_( @@ -154,20 +152,6 @@ def validate_mclk_divisible_by_3(config): return config -# Key for storing legacy driver setting in CORE.data -I2S_USE_LEGACY_DRIVER_KEY = "i2s_use_legacy_driver" - - -def _get_use_legacy_driver(): - """Get the legacy driver setting from CORE.data.""" - return CORE.data.get(I2S_USE_LEGACY_DRIVER_KEY) - - -def _set_use_legacy_driver(value: bool) -> None: - """Set the legacy driver setting in CORE.data.""" - CORE.data[I2S_USE_LEGACY_DRIVER_KEY] = value - - def i2s_audio_component_schema( class_: MockObjClass, *, @@ -192,10 +176,6 @@ def i2s_audio_component_schema( *I2S_MODE_OPTIONS, lower=True ), cv.Optional(CONF_USE_APLL, default=False): cv.boolean, - cv.Optional(CONF_BITS_PER_CHANNEL, default="default"): cv.All( - cv.Any(cv.float_with_unit("bits", "bit"), "default"), - cv.one_of(*I2S_BITS_PER_CHANNEL), - ), cv.Optional(CONF_MCLK_MULTIPLE, default=256): cv.one_of(*I2S_MCLK_MULTIPLE), } ) @@ -203,59 +183,28 @@ def i2s_audio_component_schema( async def register_i2s_audio_component(var, config): await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) - if use_legacy(): - cg.add(var.set_i2s_mode(I2S_MODE_OPTIONS[config[CONF_I2S_MODE]])) - cg.add(var.set_channel(I2S_CHANNELS[config[CONF_CHANNEL]])) - cg.add( - var.set_bits_per_sample(I2S_BITS_PER_SAMPLE[config[CONF_BITS_PER_SAMPLE]]) - ) - cg.add( - var.set_bits_per_channel( - I2S_BITS_PER_CHANNEL[config[CONF_BITS_PER_CHANNEL]] - ) - ) - else: - cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) - slot_mode = config[CONF_CHANNEL] - if slot_mode != CONF_STEREO: - slot_mode = CONF_MONO - slot_mask = config[CONF_CHANNEL] - if slot_mask not in [CONF_LEFT, CONF_RIGHT]: - slot_mask = CONF_BOTH - cg.add(var.set_slot_mode(I2S_SLOT_MODE[slot_mode])) - cg.add(var.set_std_slot_mask(I2S_STD_SLOT_MASK[slot_mask])) - cg.add(var.set_slot_bit_width(I2S_SLOT_BIT_WIDTH[config[CONF_BITS_PER_SAMPLE]])) + cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) + slot_mode = config[CONF_CHANNEL] + if slot_mode != CONF_STEREO: + slot_mode = CONF_MONO + slot_mask = config[CONF_CHANNEL] + if slot_mask not in [CONF_LEFT, CONF_RIGHT]: + slot_mask = CONF_BOTH + cg.add(var.set_slot_mode(I2S_SLOT_MODE[slot_mode])) + cg.add(var.set_std_slot_mask(I2S_STD_SLOT_MASK[slot_mask])) + cg.add(var.set_slot_bit_width(I2S_SLOT_BIT_WIDTH[config[CONF_BITS_PER_SAMPLE]])) cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE])) cg.add(var.set_use_apll(config[CONF_USE_APLL])) cg.add(var.set_mclk_multiple(I2S_MCLK_MULTIPLE[config[CONF_MCLK_MULTIPLE]])) -def validate_use_legacy(value): - if CONF_USE_LEGACY in value: - existing_value = _get_use_legacy_driver() - if (existing_value is not None) and (existing_value != value[CONF_USE_LEGACY]): - raise cv.Invalid( - f"All i2s_audio components must set {CONF_USE_LEGACY} to the same value." - ) - if (not value[CONF_USE_LEGACY]) and (CORE.using_arduino): - raise cv.Invalid("Arduino supports only the legacy i2s driver") - _set_use_legacy_driver(value[CONF_USE_LEGACY]) - elif CORE.using_arduino: - _set_use_legacy_driver(True) - return value - - -CONFIG_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(I2SAudioComponent), - cv.Required(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_USE_LEGACY): cv.boolean, - }, - ), - validate_use_legacy, +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(I2SAudioComponent), + cv.Required(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, + }, ) @@ -311,13 +260,6 @@ def _assign_ports() -> None: def _final_validate(_): - from esphome.components.esp32 import idf_version - - if use_legacy() and idf_version() >= cv.Version(6, 0, 0): - raise cv.Invalid( - "The legacy I2S driver is not available in ESP-IDF 6.0+. " - "Set 'use_legacy: false' in i2s_audio configuration." - ) i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -329,10 +271,6 @@ def _final_validate(_): _assign_ports() -def use_legacy(): - return _get_use_legacy_driver() - - FINAL_VALIDATE_SCHEMA = _final_validate @@ -349,11 +287,6 @@ async def to_code(config): # Re-enable ESP-IDF's I2S driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_i2s") - if use_legacy(): - cg.add_define("USE_I2S_LEGACY") - # Legacy I2S API lives in the "driver" shim component (driver/i2s.h) - include_builtin_idf_component("driver") - # Helps avoid callbacks being skipped due to processor load add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index f26ffddd467..5b260fa7ede 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -6,11 +6,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include -#ifdef USE_I2S_LEGACY -#include -#else #include -#endif namespace esphome { namespace i2s_audio { @@ -19,33 +15,19 @@ class I2SAudioComponent; class I2SAudioBase : public Parented { public: -#ifdef USE_I2S_LEGACY - void set_i2s_mode(i2s_mode_t mode) { this->i2s_mode_ = mode; } - void set_channel(i2s_channel_fmt_t channel) { this->channel_ = channel; } - void set_bits_per_sample(i2s_bits_per_sample_t bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } - void set_bits_per_channel(i2s_bits_per_chan_t bits_per_channel) { this->bits_per_channel_ = bits_per_channel; } -#else void set_i2s_role(i2s_role_t role) { this->i2s_role_ = role; } void set_slot_mode(i2s_slot_mode_t slot_mode) { this->slot_mode_ = slot_mode; } void set_std_slot_mask(i2s_std_slot_mask_t std_slot_mask) { this->std_slot_mask_ = std_slot_mask; } void set_slot_bit_width(i2s_slot_bit_width_t slot_bit_width) { this->slot_bit_width_ = slot_bit_width; } -#endif void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; } void set_use_apll(uint32_t use_apll) { this->use_apll_ = use_apll; } void set_mclk_multiple(i2s_mclk_multiple_t mclk_multiple) { this->mclk_multiple_ = mclk_multiple; } protected: -#ifdef USE_I2S_LEGACY - i2s_mode_t i2s_mode_{}; - i2s_channel_fmt_t channel_; - i2s_bits_per_sample_t bits_per_sample_; - i2s_bits_per_chan_t bits_per_channel_; -#else i2s_role_t i2s_role_{}; i2s_slot_mode_t slot_mode_; i2s_std_slot_mask_t std_slot_mask_; i2s_slot_bit_width_t slot_bit_width_; -#endif uint32_t sample_rate_; bool use_apll_; i2s_mclk_multiple_t mclk_multiple_; @@ -57,17 +39,6 @@ class I2SAudioOut : public I2SAudioBase {}; class I2SAudioComponent : public Component { public: -#ifdef USE_I2S_LEGACY - i2s_pin_config_t get_pin_config() const { - return { - .mck_io_num = this->mclk_pin_, - .bck_io_num = this->bclk_pin_, - .ws_io_num = this->lrclk_pin_, - .data_out_num = I2S_PIN_NO_CHANGE, - .data_in_num = I2S_PIN_NO_CHANGE, - }; - } -#else i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, .bclk = (gpio_num_t) this->bclk_pin_, @@ -80,7 +51,6 @@ class I2SAudioComponent : public Component { .ws_inv = false, }}; } -#endif void set_mclk_pin(int pin) { this->mclk_pin_ = pin; } void set_bclk_pin(int pin) { this->bclk_pin_ = pin; } @@ -101,13 +71,8 @@ class I2SAudioComponent : public Component { I2SAudioIn *audio_in_{nullptr}; I2SAudioOut *audio_out_{nullptr}; -#ifdef USE_I2S_LEGACY - int mclk_pin_{I2S_PIN_NO_CHANGE}; - int bclk_pin_{I2S_PIN_NO_CHANGE}; -#else int mclk_pin_{I2S_GPIO_UNUSED}; int bclk_pin_{I2S_GPIO_UNUSED}; -#endif int lrclk_pin_; int port_{}; }; diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index 426b211f475..b366d4fb05c 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -1,121 +1,7 @@ -from esphome import pins -import esphome.codegen as cg -from esphome.components import esp32, media_player import esphome.config_validation as cv -from esphome.const import CONF_MODE -from .. import ( - CONF_I2S_AUDIO_ID, - CONF_I2S_DOUT_PIN, - CONF_LEFT, - CONF_MONO, - CONF_RIGHT, - CONF_STEREO, - I2SAudioComponent, - I2SAudioOut, - i2s_audio_ns, - use_legacy, +CONFIG_SCHEMA = cv.invalid( + "The I2S audio media player has been removed. " + "Use the speaker media player component instead. " + "See https://esphome.io/components/media_player/speaker.html for details." ) - -CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["i2s_audio"] - -I2SAudioMediaPlayer = i2s_audio_ns.class_( - "I2SAudioMediaPlayer", cg.Component, media_player.MediaPlayer, I2SAudioOut -) - -i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t") - - -CONF_MUTE_PIN = "mute_pin" -CONF_AUDIO_ID = "audio_id" -CONF_DAC_TYPE = "dac_type" -CONF_I2S_COMM_FMT = "i2s_comm_fmt" - -INTERNAL_DAC_OPTIONS = { - CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN, - CONF_RIGHT: i2s_dac_mode_t.I2S_DAC_CHANNEL_RIGHT_EN, - CONF_STEREO: i2s_dac_mode_t.I2S_DAC_CHANNEL_BOTH_EN, -} - -EXTERNAL_DAC_OPTIONS = [CONF_MONO, CONF_STEREO] - -NO_INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32S2] - -I2C_COMM_FMT_OPTIONS = ["lsb", "msb"] - - -def validate_esp32_variant(config): - if config[CONF_DAC_TYPE] != "internal": - return config - variant = esp32.get_esp32_variant() - if variant in NO_INTERNAL_DAC_VARIANTS: - raise cv.Invalid(f"{variant} does not have an internal DAC") - return config - - -CONFIG_SCHEMA = cv.All( - cv.typed_schema( - { - "internal": media_player.media_player_schema(I2SAudioMediaPlayer) - .extend( - { - cv.GenerateID(CONF_I2S_AUDIO_ID): cv.use_id(I2SAudioComponent), - cv.Required(CONF_MODE): cv.enum(INTERNAL_DAC_OPTIONS, lower=True), - } - ) - .extend(cv.COMPONENT_SCHEMA), - "external": media_player.media_player_schema(I2SAudioMediaPlayer) - .extend( - { - cv.GenerateID(CONF_I2S_AUDIO_ID): cv.use_id(I2SAudioComponent), - cv.Required( - CONF_I2S_DOUT_PIN - ): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_MUTE_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_MODE, default="mono"): cv.one_of( - *EXTERNAL_DAC_OPTIONS, lower=True - ), - cv.Optional(CONF_I2S_COMM_FMT, default="msb"): cv.one_of( - *I2C_COMM_FMT_OPTIONS, lower=True - ), - } - ) - .extend(cv.COMPONENT_SCHEMA), - }, - key=CONF_DAC_TYPE, - ), - cv.only_with_arduino, - validate_esp32_variant, -) - - -def _final_validate(_): - if not use_legacy(): - raise cv.Invalid("I2S media player is only compatible with legacy i2s driver") - - -FINAL_VALIDATE_SCHEMA = _final_validate - - -async def to_code(config): - var = await media_player.new_media_player(config) - await cg.register_component(var, config) - - await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) - - if config[CONF_DAC_TYPE] == "internal": - cg.add(var.set_internal_dac_mode(config[CONF_MODE])) - else: - cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN])) - if CONF_MUTE_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_MUTE_PIN]) - cg.add(var.set_mute_pin(pin)) - cg.add(var.set_external_dac_channels(2 if config[CONF_MODE] == "stereo" else 1)) - cg.add(var.set_i2s_comm_fmt_lsb(config[CONF_I2S_COMM_FMT] == "lsb")) - - cg.add_library("WiFi", None) - cg.add_library("NetworkClientSecure", None) - cg.add_library("HTTPClient", None) - cg.add_library("esphome/ESP32-audioI2S", "2.3.0") - cg.add_build_flag("-DAUDIO_NO_SD_FS") diff --git a/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp b/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp deleted file mode 100644 index 369c964a859..00000000000 --- a/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include "i2s_audio_media_player.h" - -#ifdef USE_ESP32_FRAMEWORK_ARDUINO - -#include "esphome/core/log.h" - -namespace esphome { -namespace i2s_audio { - -static const char *const TAG = "audio"; - -void I2SAudioMediaPlayer::control(const media_player::MediaPlayerCall &call) { - media_player::MediaPlayerState play_state = media_player::MEDIA_PLAYER_STATE_PLAYING; - auto announcement = call.get_announcement(); - if (announcement.has_value()) { - play_state = *announcement ? media_player::MEDIA_PLAYER_STATE_ANNOUNCING : media_player::MEDIA_PLAYER_STATE_PLAYING; - } - auto media_url = call.get_media_url(); - if (media_url.has_value()) { - this->current_url_ = media_url; - if (this->i2s_state_ != I2S_STATE_STOPPED && this->audio_ != nullptr) { - if (this->audio_->isRunning()) { - this->audio_->stopSong(); - } - this->audio_->connecttohost(media_url->c_str()); - this->state = play_state; - } else { - this->start(); - } - } - - if (play_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) { - this->is_announcement_ = true; - } - - auto vol = call.get_volume(); - if (vol.has_value()) { - this->volume = *vol; - this->set_volume_(volume); - this->unmute_(); - } - auto cmd = call.get_command(); - if (cmd.has_value()) { - switch (*cmd) { - case media_player::MEDIA_PLAYER_COMMAND_MUTE: - this->mute_(); - break; - case media_player::MEDIA_PLAYER_COMMAND_UNMUTE: - this->unmute_(); - break; - case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP: { - float new_volume = this->volume + 0.1f; - if (new_volume > 1.0f) - new_volume = 1.0f; - this->set_volume_(new_volume); - this->unmute_(); - break; - } - case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: { - float new_volume = this->volume - 0.1f; - if (new_volume < 0.0f) - new_volume = 0.0f; - this->set_volume_(new_volume); - this->unmute_(); - break; - } - default: - break; - } - if (this->i2s_state_ != I2S_STATE_RUNNING) { - return; - } - switch (*cmd) { - case media_player::MEDIA_PLAYER_COMMAND_PLAY: - if (!this->audio_->isRunning()) - this->audio_->pauseResume(); - this->state = play_state; - break; - case media_player::MEDIA_PLAYER_COMMAND_PAUSE: - if (this->audio_->isRunning()) - this->audio_->pauseResume(); - this->state = media_player::MEDIA_PLAYER_STATE_PAUSED; - break; - case media_player::MEDIA_PLAYER_COMMAND_STOP: - this->stop(); - break; - case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: - this->audio_->pauseResume(); - if (this->audio_->isRunning()) { - this->state = media_player::MEDIA_PLAYER_STATE_PLAYING; - } else { - this->state = media_player::MEDIA_PLAYER_STATE_PAUSED; - } - break; - default: - break; - } - } - this->publish_state(); -} - -void I2SAudioMediaPlayer::mute_() { - if (this->mute_pin_ != nullptr) { - this->mute_pin_->digital_write(true); - } else { - this->set_volume_(0.0f, false); - } - this->muted_ = true; -} -void I2SAudioMediaPlayer::unmute_() { - if (this->mute_pin_ != nullptr) { - this->mute_pin_->digital_write(false); - } else { - this->set_volume_(this->volume, false); - } - this->muted_ = false; -} -void I2SAudioMediaPlayer::set_volume_(float volume, bool publish) { - if (this->audio_ != nullptr) - this->audio_->setVolume(remap(volume, 0.0f, 1.0f, 0, 21)); - if (publish) - this->volume = volume; -} - -void I2SAudioMediaPlayer::setup() { this->state = media_player::MEDIA_PLAYER_STATE_IDLE; } - -void I2SAudioMediaPlayer::loop() { - switch (this->i2s_state_) { - case I2S_STATE_STARTING: - this->start_(); - break; - case I2S_STATE_RUNNING: - this->play_(); - break; - case I2S_STATE_STOPPING: - this->stop_(); - break; - case I2S_STATE_STOPPED: - break; - } -} - -void I2SAudioMediaPlayer::play_() { - this->audio_->loop(); - if ((this->state == media_player::MEDIA_PLAYER_STATE_PLAYING || - this->state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) && - !this->audio_->isRunning()) { - this->stop(); - } -} - -void I2SAudioMediaPlayer::start() { this->i2s_state_ = I2S_STATE_STARTING; } -void I2SAudioMediaPlayer::start_() { - if (!this->parent_->try_lock()) { - return; // Waiting for another i2s to return lock - } - -#if SOC_I2S_SUPPORTS_DAC - if (this->internal_dac_mode_ != I2S_DAC_CHANNEL_DISABLE) { - this->audio_ = make_unique