From b14255797941f285fcb6df301b96275a4c601257 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 08:26:06 -1000 Subject: [PATCH 01/41] [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 e1ceefeacd..f519d79aa1 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 cc73c01df4..901d9bc0bb 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 0000000000..77b1a22d66 --- /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 a69b8da480..ad69192af9 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 96328e68c7..3bcbd88085 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 513c70e17e..390ac8ddd7 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 0000000000..78b2b952fc --- /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 0000000000..7953198b7e --- /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 02/41] [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 4f62ce7a94..4cca0cea5d 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 1478d9544e..a847e34b02 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 03/41] [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 13f2fa59bd..f119e28e78 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 4191c45de1..4e2a306fc9 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 04/41] [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 b29e4c2154..ee6c2ee471 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 05/41] [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 5054a62207..7905739b15 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 06/41] [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 91973d8e33..195b96a19e 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 07/41] [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 5dff1cb0ae..5f46dadfa8 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 08/41] [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 1eac53e9b2..32689dab27 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 09/41] 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 1a0c54da6d..2ef1a5af31 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 10/41] 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 6376cf877e..3b5e9f0d15 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 0ed41d99c7..4aa63f6a16 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 11/41] [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 263603704a..c9fe3a2e6e 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 12/41] [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 eaefabf75b..4aca4f0fae 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 13/41] [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 65b09b93f6..977a239497 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 43064498cc..0000000000 --- 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 cfccf7e01f..f26ffddd46 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 bf583b9f81..aa15625cd7 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 eb4506071e..a17cb0d84a 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 14/41] [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 4083019643..1dda6204fe 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 0eed9e8eb642dab40ddbc6b0ea65335330500304 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:00:20 -1000 Subject: [PATCH 15/41] [api] Extract overflow buffer from frame helper into APIOverflowBuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TCP send overflow buffer code was embedded directly in APIFrameHelper, making it appear to be part of the primary send path. In reality, it is rarely used in production — only when the kernel TCP send buffer is full due to a slow client, congested network, or heavy logging. Extract it into a dedicated APIOverflowBuffer class with clear documentation about its purpose and usage frequency. --- esphome/components/api/api_frame_helper.cpp | 144 ++++-------------- esphome/components/api/api_frame_helper.h | 44 +++--- .../components/api/api_overflow_buffer.cpp | 72 +++++++++ esphome/components/api/api_overflow_buffer.h | 72 +++++++++ 4 files changed, 191 insertions(+), 141 deletions(-) create mode 100644 esphome/components/api/api_overflow_buffer.cpp create mode 100644 esphome/components/api/api_overflow_buffer.h diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index fbee294022..5d51204cb6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -100,72 +100,22 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("UNKNOWN"); } -// Default implementation for loop - handles sending buffered data +// Default implementation for loop - handles draining overflow buffer APIError APIFrameHelper::loop() { - if (this->tx_buf_count_ > 0) { - APIError err = try_send_tx_buf_(); - if (err != APIError::OK && err != APIError::WOULD_BLOCK) { - return err; - } + if (!this->overflow_buf_.empty() && this->overflow_buf_.try_drain(this->socket_.get()) == -1) { + const int sav_errno = errno; + HELPER_LOG("Socket write failed with errno %d", sav_errno); + if (this->check_socket_write_err_(sav_errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; } - return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination -} - -// Common socket write error handling -APIError APIFrameHelper::handle_socket_write_error_() { - const int err = errno; - if (err == EWOULDBLOCK || err == EAGAIN) { - return APIError::WOULD_BLOCK; - } - HELPER_LOG("Socket write failed with errno %d", err); - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; -} - -// Helper method to buffer data from IOVs -void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, - uint16_t offset) { - // Check if queue is full - if (this->tx_buf_count_ >= API_MAX_SEND_QUEUE) { - HELPER_LOG("Send queue full (%u buffers), dropping connection", this->tx_buf_count_); - this->state_ = State::FAILED; - return; - } - - uint16_t buffer_size = total_write_len - offset; - auto &buffer = this->tx_buf_[this->tx_buf_tail_]; - buffer = std::make_unique(SendBuffer{ - .data = std::make_unique(buffer_size), - .size = buffer_size, - .offset = 0, - }); - - uint16_t to_skip = offset; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - // Skip this entire segment - to_skip -= static_cast(iov[i].iov_len); - } else { - // Include this segment (partially or fully) - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(buffer->data.get() + write_pos, src, len); - write_pos += len; - to_skip = 0; - } - } - - // Update circular buffer tracking - this->tx_buf_tail_ = (this->tx_buf_tail_ + 1) % API_MAX_SEND_QUEUE; - this->tx_buf_count_++; + // Convert WOULD_BLOCK to OK to avoid connection termination + return APIError::OK; } // This method writes data to socket or buffers it APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { - // Returns APIError::OK if successful (or would block, but data has been buffered) - // Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED + // Returns APIError::OK if all data was sent or successfully queued. + // Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED. if (iovcnt == 0) return APIError::OK; // Nothing to do, success @@ -176,74 +126,34 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } #endif - // Try to send any existing buffered data first if there is any - if (this->tx_buf_count_ > 0) { - APIError send_result = try_send_tx_buf_(); - // If real error occurred (not just WOULD_BLOCK), return it - if (send_result != APIError::OK && send_result != APIError::WOULD_BLOCK) { - return send_result; - } - - // If there is still data in the buffer, we can't send, buffer - // the new data and return - if (this->tx_buf_count_ > 0) { - this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0); - return APIError::OK; // Success, data buffered - } + // If there is already backlogged data, try to drain then queue behind it + if (!this->overflow_buf_.empty()) { + this->overflow_buf_.try_drain(this->socket_.get()); + // If still backlogged, queue new data behind it; otherwise fall through to direct send + if (!this->overflow_buf_.empty()) + return this->enqueue_or_fail_(iov, iovcnt, total_write_len, 0); } - // Try to send directly if no buffered data + // No backlog — try to send directly // Optimize for single iovec case (common for plaintext API) ssize_t sent = (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) { - APIError err = this->handle_socket_write_error_(); - if (err == APIError::WOULD_BLOCK) { - // Socket would block, buffer the data - this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0); - return APIError::OK; // Success, data buffered - } - return err; // Socket write failed - } else if (static_cast(sent) < total_write_len) { - // Partially sent, buffer the remaining data - this->buffer_data_from_iov_(iov, iovcnt, total_write_len, static_cast(sent)); + const int sav_errno = errno; + HELPER_LOG("Socket write failed with errno %d", sav_errno); + if (this->check_socket_write_err_(sav_errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; + // Socket would block — queue everything + return this->enqueue_or_fail_(iov, iovcnt, total_write_len, 0); } - return APIError::OK; // Success, all data sent or buffered -} - -// Common implementation for trying to send buffered data -// IMPORTANT: Caller MUST ensure tx_buf_count_ > 0 before calling this method -APIError APIFrameHelper::try_send_tx_buf_() { - // Try to send from tx_buf - we assume it's not empty as it's the caller's responsibility to check - while (this->tx_buf_count_ > 0) { - // Get the first buffer in the queue - SendBuffer *front_buffer = this->tx_buf_[this->tx_buf_head_].get(); - - // Try to send the remaining data in this buffer - ssize_t sent = this->socket_->write(front_buffer->current_data(), front_buffer->remaining()); - - if (sent == -1) { - return this->handle_socket_write_error_(); - } else if (sent == 0) { - // Nothing sent but not an error - return APIError::WOULD_BLOCK; - } else if (static_cast(sent) < front_buffer->remaining()) { - // Partially sent, update offset - // Cast to ensure no overflow issues with uint16_t - front_buffer->offset += static_cast(sent); - return APIError::WOULD_BLOCK; // Stop processing more buffers if we couldn't send a complete buffer - } else { - // Buffer completely sent, remove it from the queue - this->tx_buf_[this->tx_buf_head_].reset(); - this->tx_buf_head_ = (this->tx_buf_head_ + 1) % API_MAX_SEND_QUEUE; - this->tx_buf_count_--; - // Continue loop to try sending the next buffer - } + if (static_cast(sent) < total_write_len) { + // Partially sent — queue the remainder + return this->enqueue_or_fail_(iov, iovcnt, total_write_len, static_cast(sent)); } - return APIError::OK; // All buffers sent successfully + return APIError::OK; } const char *APIFrameHelper::get_peername_to(std::span buf) const { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index e78c71507c..133f18c104 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -9,6 +9,7 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_overflow_buffer.h" #include "esphome/components/socket/socket.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -106,7 +107,7 @@ class APIFrameHelper { virtual APIError init() = 0; virtual APIError loop(); virtual APIError read_packet(ReadPacketBuffer *buffer) = 0; - bool can_write_without_blocking() { return this->state_ == State::DATA && this->tx_buf_count_ == 0; } + bool can_write_without_blocking() { return this->state_ == State::DATA && this->overflow_buf_.empty(); } int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); } APIError close() { if (state_ == State::CLOSED) @@ -189,28 +190,26 @@ class APIFrameHelper { } protected: - // Buffer containing data to be sent - struct SendBuffer { - std::unique_ptr data; - uint16_t size{0}; // Total size of the buffer - uint16_t offset{0}; // Current offset within the buffer - - // Using uint16_t reduces memory usage since ESPHome API messages are limited to UINT16_MAX (65535) bytes - uint16_t remaining() const { return size - offset; } - const uint8_t *current_data() const { return data.get() + offset; } - }; - // Common implementation for writing raw data to socket APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); - // Try to send data from the tx buffer - APIError try_send_tx_buf_(); + // Check if a socket write errno is a hard error (not WOULD_BLOCK/EAGAIN). + // Returns WOULD_BLOCK for transient errors, SOCKET_WRITE_FAILED for hard errors. + APIError check_socket_write_err_(int err) { + if (err == EWOULDBLOCK || err == EAGAIN) + return APIError::WOULD_BLOCK; + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } - // Helper method to buffer data from IOVs - void buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t offset); - - // Common socket write error handling - APIError handle_socket_write_error_(); + // Enqueue IOV data into the overflow buffer, or fail the connection if full + APIError enqueue_or_fail_(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_len, skip)) { + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; + } // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; @@ -245,8 +244,8 @@ class APIFrameHelper { return APIError::WOULD_BLOCK; } - // Containers (size varies, but typically 12+ bytes on 32-bit) - std::array, API_MAX_SEND_QUEUE> tx_buf_; + // Backlog for unsent data when TCP send buffer is full (rarely used in production) + APIOverflowBuffer overflow_buf_; APIBuffer rx_buf_; // Client name buffer - stores name from Hello message or initial peername @@ -257,9 +256,6 @@ class APIFrameHelper { State state_{State::INITIALIZE}; uint8_t frame_header_padding_{0}; uint8_t frame_footer_size_{0}; - uint8_t tx_buf_head_{0}; - uint8_t tx_buf_tail_{0}; - uint8_t tx_buf_count_{0}; // 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. diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp new file mode 100644 index 0000000000..af3b670e0e --- /dev/null +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -0,0 +1,72 @@ +#include "api_overflow_buffer.h" +#ifdef USE_API +#include + +namespace esphome::api { + +APIOverflowBuffer::~APIOverflowBuffer() { + for (auto *entry : this->queue_) { + if (entry != nullptr) + Entry::destroy(entry); + } +} + +ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { + while (this->count_ > 0) { + Entry *front = this->queue_[this->head_]; + + ssize_t sent = socket->write(front->current_data(), front->remaining()); + + if (sent <= 0) { + // -1 = error (caller checks errno), 0 = would block + return sent; + } + + if (static_cast(sent) < front->remaining()) { + // Partially sent, update offset and stop + front->offset += static_cast(sent); + return sent; + } + + // Entry fully sent — free it and advance + Entry::destroy(front); + this->queue_[this->head_] = nullptr; + this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->count_--; + } + + return 0; // All drained +} + +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { + if (this->count_ >= API_MAX_SEND_QUEUE) + return false; + + uint16_t buffer_size = total_len - skip; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0}; + this->queue_[this->tail_] = entry; + + uint16_t to_skip = skip; + uint16_t write_pos = 0; + + for (int i = 0; i < iovcnt; i++) { + if (to_skip >= iov[i].iov_len) { + to_skip -= static_cast(iov[i].iov_len); + } else { + const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; + uint16_t len = static_cast(iov[i].iov_len) - to_skip; + std::memcpy(entry->data + write_pos, src, len); + write_pos += len; + to_skip = 0; + } + } + + this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; + this->count_++; + return true; +} + +} // namespace esphome::api + +#endif // USE_API diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h new file mode 100644 index 0000000000..b0f79e1e9a --- /dev/null +++ b/esphome/components/api/api_overflow_buffer.h @@ -0,0 +1,72 @@ +#pragma once +#include +#include +#include + +#include "esphome/core/defines.h" +#ifdef USE_API + +#include "esphome/components/socket/socket.h" + +namespace esphome::api { + +/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. +/// +/// Under normal operation this buffer is **never used** — data goes straight +/// from the frame helper to the socket. It only fills when the kernel TCP +/// send buffer is full (slow client, congested network, heavy logging). +/// The queue drains automatically on subsequent write/loop calls once the +/// socket becomes writable again. +/// +/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python +/// config). If the queue fills completely the connection is marked failed. +class APIOverflowBuffer { + public: + /// A single heap-allocated send-backlog entry. + /// Lifetime is manually managed — see destroy(). + struct Entry { + uint8_t *data; + uint16_t size; // Total size of the buffer + uint16_t offset; // Current send offset within the buffer + + uint16_t remaining() const { return this->size - this->offset; } + const uint8_t *current_data() const { return this->data + this->offset; } + + /// Free this entry and its data buffer. + static void destroy(Entry *entry) { + delete[] entry->data; + delete entry; // NOLINT(cppcoreguidelines-owning-memory) + } + }; + + ~APIOverflowBuffer(); + + /// True when no backlogged data is waiting. + bool empty() const { return this->count_ == 0; } + + /// True when the queue has no room for another entry. + bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } + + /// Number of entries currently queued. + uint8_t count() const { return this->count_; } + + /// Try to drain queued data to the socket. + /// Returns bytes-written >= 0 on success/partial, -1 on hard error (errno set). + /// Frees entries as they are fully sent. + ssize_t try_drain(socket::Socket *socket); + + /// Enqueue unsent IOV data into the backlog. + /// Copies iov data starting at byte offset `skip` into a new entry. + /// Returns false if the queue is full (caller should fail the connection). + bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + + protected: + std::array queue_{}; + uint8_t head_{0}; + uint8_t tail_{0}; + uint8_t count_{0}; +}; + +} // namespace esphome::api + +#endif // USE_API From 49dd23d2a117a909ef75bd330bf79d70dd7887db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:01:51 -1000 Subject: [PATCH 16/41] Fix doc comment: LWIP not kernel --- esphome/components/api/api_overflow_buffer.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index b0f79e1e9a..bfd7acb007 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -13,7 +13,7 @@ namespace esphome::api { /// Circular queue of heap-allocated byte buffers used as a TCP send backlog. /// /// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the kernel TCP +/// from the frame helper to the socket. It only fills when the LWIP TCP /// send buffer is full (slow client, congested network, heavy logging). /// The queue drains automatically on subsequent write/loop calls once the /// socket becomes writable again. From c03dd86796b8fa91ba58bf24d157373175ced17e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:03:10 -1000 Subject: [PATCH 17/41] Replace modulo with compare-and-reset for circular buffer indices Modulo generates a full division on ESP8266 since API_MAX_SEND_QUEUE is not a power of 2. Use increment + compare instead. --- esphome/components/api/api_overflow_buffer.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index af3b670e0e..76b70c624e 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -31,7 +31,8 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { // Entry fully sent — free it and advance Entry::destroy(front); this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + if (++this->head_ >= API_MAX_SEND_QUEUE) + this->head_ = 0; this->count_--; } @@ -62,7 +63,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_ } } - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; + if (++this->tail_ >= API_MAX_SEND_QUEUE) + this->tail_ = 0; this->count_++; return true; } From bdeea2983681907f83de070b0078fe41aae1bb81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:04:32 -1000 Subject: [PATCH 18/41] Revert "Replace modulo with compare-and-reset for circular buffer indices" This reverts commit c03dd86796b8fa91ba58bf24d157373175ced17e. --- esphome/components/api/api_overflow_buffer.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 76b70c624e..af3b670e0e 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -31,8 +31,7 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { // Entry fully sent — free it and advance Entry::destroy(front); this->queue_[this->head_] = nullptr; - if (++this->head_ >= API_MAX_SEND_QUEUE) - this->head_ = 0; + this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; this->count_--; } @@ -63,8 +62,7 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_ } } - if (++this->tail_ >= API_MAX_SEND_QUEUE) - this->tail_ = 0; + this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } From 0bccf4a42d565df389e703426dfd7bf2f154284f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:06:18 -1000 Subject: [PATCH 19/41] power of 2 --- esphome/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9772e6afca..d3001777c5 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -303,7 +303,7 @@ CONFIG_SCHEMA = cv.All( # Platform defaults based on available RAM and typical message rates: cv.SplitDefault( CONF_MAX_SEND_QUEUE, - esp8266=5, # Limited RAM, need to fail fast + esp8266=4, # Limited RAM, need to fail fast esp32=8, # More RAM, can buffer more rp2040=5, # Limited RAM bk72xx=8, # Moderate RAM From 2d8c7938c28d479dc4844b0fa24a0e3ee9d32dc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:06:52 -1000 Subject: [PATCH 20/41] power of 2 --- esphome/components/api/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index d3001777c5..aabe708a9e 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -301,6 +301,7 @@ CONFIG_SCHEMA = cv.All( # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size # Platform defaults based on available RAM and typical message rates: + # CONF_MAX_SEND_QUEUE should be a power 2 for best performance cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast From 6e808145078bc8278747104579be3ad2ec34704b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:09:22 -1000 Subject: [PATCH 21/41] Propagate drain errors in write_raw_ to preserve original behavior --- esphome/components/api/api_frame_helper.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 5d51204cb6..1b9be1007a 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -128,7 +128,13 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // If there is already backlogged data, try to drain then queue behind it if (!this->overflow_buf_.empty()) { - this->overflow_buf_.try_drain(this->socket_.get()); + ssize_t ret = this->overflow_buf_.try_drain(this->socket_.get()); + if (ret == -1) { + const int sav_errno = errno; + HELPER_LOG("Socket write failed with errno %d", sav_errno); + if (this->check_socket_write_err_(sav_errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; + } // If still backlogged, queue new data behind it; otherwise fall through to direct send if (!this->overflow_buf_.empty()) return this->enqueue_or_fail_(iov, iovcnt, total_write_len, 0); From 488c395fbb9be61bcd34579d959f3604394ae6c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:10:52 -1000 Subject: [PATCH 22/41] Simplify: no need to save errno before HELPER_LOG --- esphome/components/api/api_frame_helper.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 1b9be1007a..25249546a3 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -128,11 +128,9 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // If there is already backlogged data, try to drain then queue behind it if (!this->overflow_buf_.empty()) { - ssize_t ret = this->overflow_buf_.try_drain(this->socket_.get()); - if (ret == -1) { - const int sav_errno = errno; - HELPER_LOG("Socket write failed with errno %d", sav_errno); - if (this->check_socket_write_err_(sav_errno) != APIError::WOULD_BLOCK) + if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { + HELPER_LOG("Socket write failed with errno %d", errno); + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; } // If still backlogged, queue new data behind it; otherwise fall through to direct send From 8a8e05fdb2d5913453f0c67f473096d3503dba76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:11:34 -1000 Subject: [PATCH 23/41] Remove remaining sav_errno locals --- esphome/components/api/api_frame_helper.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 25249546a3..fe499c3825 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -103,9 +103,8 @@ const LogString *api_error_to_logstr(APIError err) { // Default implementation for loop - handles draining overflow buffer APIError APIFrameHelper::loop() { if (!this->overflow_buf_.empty() && this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - const int sav_errno = errno; - HELPER_LOG("Socket write failed with errno %d", sav_errno); - if (this->check_socket_write_err_(sav_errno) != APIError::WOULD_BLOCK) + HELPER_LOG("Socket write failed with errno %d", errno); + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; } // Convert WOULD_BLOCK to OK to avoid connection termination @@ -144,9 +143,8 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) { - const int sav_errno = errno; - HELPER_LOG("Socket write failed with errno %d", sav_errno); - if (this->check_socket_write_err_(sav_errno) != APIError::WOULD_BLOCK) + HELPER_LOG("Socket write failed with errno %d", errno); + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; // Socket would block — queue everything return this->enqueue_or_fail_(iov, iovcnt, total_write_len, 0); From 7846c4273039c7686d8b35741cc0f51dd5a7446f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:17:15 -1000 Subject: [PATCH 24/41] Rename base loop() to try_drain_overflow_buffer_() inline helper The base class loop() only drained the overflow buffer. Rename to make intent clear and inline it so the compiler can optimize across the call boundary. Make loop() pure virtual since subclasses always override it. --- esphome/components/api/api_frame_helper.cpp | 11 ----------- esphome/components/api/api_frame_helper.h | 12 +++++++++++- esphome/components/api/api_frame_helper_noise.cpp | 3 +-- .../components/api/api_frame_helper_plaintext.cpp | 3 +-- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index fe499c3825..de9d0e703d 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -100,17 +100,6 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("UNKNOWN"); } -// Default implementation for loop - handles draining overflow buffer -APIError APIFrameHelper::loop() { - if (!this->overflow_buf_.empty() && this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - HELPER_LOG("Socket write failed with errno %d", errno); - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) - return APIError::SOCKET_WRITE_FAILED; - } - // Convert WOULD_BLOCK to OK to avoid connection termination - return APIError::OK; -} - // This method writes data to socket or buffers it APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { // Returns APIError::OK if all data was sent or successfully queued. diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 133f18c104..5709a77854 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -105,7 +105,7 @@ class APIFrameHelper { } virtual ~APIFrameHelper() = default; virtual APIError init() = 0; - virtual APIError loop(); + virtual APIError loop() = 0; virtual APIError read_packet(ReadPacketBuffer *buffer) = 0; bool can_write_without_blocking() { return this->state_ == State::DATA && this->overflow_buf_.empty(); } int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); } @@ -190,6 +190,16 @@ class APIFrameHelper { } protected: + // Drain any backlogged overflow data to the socket. + // Returns OK even for WOULD_BLOCK to avoid connection termination. + APIError try_drain_overflow_buffer_() { + if (!this->overflow_buf_.empty() && this->overflow_buf_.try_drain(this->socket_.get()) == -1) { + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; + } + // Common implementation for writing raw data to socket APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index b635d84f16..8e5300abd7 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -153,8 +153,7 @@ APIError APINoiseFrameHelper::loop() { } } - // Use base class implementation for buffer sending - return APIFrameHelper::loop(); + return this->try_drain_overflow_buffer_(); } /** Read a packet into the rx_buf_. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index e97b558fa3..99b880c88c 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -64,8 +64,7 @@ APIError APIPlaintextFrameHelper::loop() { if (state_ != State::DATA) { return APIError::BAD_STATE; } - // Use base class implementation for buffer sending - return APIFrameHelper::loop(); + return this->try_drain_overflow_buffer_(); } /** Read a packet into the rx_buf_. From 165175eaf307a879a07d45ec7dc2b55935feeb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:19:29 -1000 Subject: [PATCH 25/41] Restore HELPER_LOG at overflow drain call sites --- esphome/components/api/api_frame_helper_noise.cpp | 6 +++++- esphome/components/api/api_frame_helper_plaintext.cpp | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 8e5300abd7..9690266971 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -153,7 +153,11 @@ APIError APINoiseFrameHelper::loop() { } } - return this->try_drain_overflow_buffer_(); + APIError err = this->try_drain_overflow_buffer_(); + if (err != APIError::OK) { + HELPER_LOG("Overflow drain failed with errno %d", errno); + } + return err; } /** Read a packet into the rx_buf_. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 99b880c88c..1790d50727 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -64,7 +64,11 @@ APIError APIPlaintextFrameHelper::loop() { if (state_ != State::DATA) { return APIError::BAD_STATE; } - return this->try_drain_overflow_buffer_(); + APIError err = this->try_drain_overflow_buffer_(); + if (err != APIError::OK) { + HELPER_LOG("Overflow drain failed with errno %d", errno); + } + return err; } /** Read a packet into the rx_buf_. From 067c9ad1faaeabd24306686983714c09e46c0828 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:24:40 -1000 Subject: [PATCH 26/41] Force inline Entry::destroy for 16 bytes flash savings --- esphome/components/api/api_overflow_buffer.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index bfd7acb007..cd26bcdb8b 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -7,6 +7,7 @@ #ifdef USE_API #include "esphome/components/socket/socket.h" +#include "esphome/core/helpers.h" namespace esphome::api { @@ -33,7 +34,7 @@ class APIOverflowBuffer { const uint8_t *current_data() const { return this->data + this->offset; } /// Free this entry and its data buffer. - static void destroy(Entry *entry) { + static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { delete[] entry->data; delete entry; // NOLINT(cppcoreguidelines-owning-memory) } From 2142bc1b767c0f0e178d8b34b0bf7d186e0529ca Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 16 Mar 2026 23:25:11 +0100 Subject: [PATCH 27/41] [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 24988cfcf8..0aa8c56719 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 824ff6afe7..0b0a5344e4 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 2893788d4b50a4f16ba3bddd14b9612a1d195978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:28:37 -1000 Subject: [PATCH 28/41] =?UTF-8?q?Remove=20dead=20iovcnt=3D=3D0=20check=20?= =?UTF-8?q?=E2=80=94=20no=20caller=20passes=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/components/api/api_frame_helper.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index de9d0e703d..299939450d 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -105,9 +105,6 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // Returns APIError::OK if all data was sent or successfully queued. // Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED. - if (iovcnt == 0) - return APIError::OK; // Nothing to do, success - #ifdef HELPER_LOG_PACKETS for (int i = 0; i < iovcnt; i++) { LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); From e8bf69d1e2c2ab34faaa86d5a1416bafd826d769 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:29:39 -1000 Subject: [PATCH 29/41] Restructure write_raw_ for single enqueue_or_fail_ tail call --- esphome/components/api/api_frame_helper.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 299939450d..1b0012e0e3 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -111,6 +111,8 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } #endif + uint16_t skip = 0; + // If there is already backlogged data, try to drain then queue behind it if (!this->overflow_buf_.empty()) { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { @@ -118,9 +120,9 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; } - // If still backlogged, queue new data behind it; otherwise fall through to direct send + // If still backlogged, queue new data behind it if (!this->overflow_buf_.empty()) - return this->enqueue_or_fail_(iov, iovcnt, total_write_len, 0); + return this->enqueue_or_fail_(iov, iovcnt, total_write_len, skip); } // No backlog — try to send directly @@ -132,16 +134,15 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ HELPER_LOG("Socket write failed with errno %d", errno); if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; - // Socket would block — queue everything - return this->enqueue_or_fail_(iov, iovcnt, total_write_len, 0); - } - - if (static_cast(sent) < total_write_len) { + } else if (static_cast(sent) >= total_write_len) { + // All data sent successfully + return APIError::OK; + } else { // Partially sent — queue the remainder - return this->enqueue_or_fail_(iov, iovcnt, total_write_len, static_cast(sent)); + skip = static_cast(sent); } - return APIError::OK; + return this->enqueue_or_fail_(iov, iovcnt, total_write_len, skip); } const char *APIFrameHelper::get_peername_to(std::span buf) const { From 1a3c47799824f92a458a9be9594131485492dfbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:30:47 -1000 Subject: [PATCH 30/41] Single enqueue_or_fail_ tail call in write_raw_ --- esphome/components/api/api_frame_helper.cpp | 33 ++++++++++----------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 1b0012e0e3..482f3aa0b9 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -113,33 +113,30 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ uint16_t skip = 0; - // If there is already backlogged data, try to drain then queue behind it + // If there is already backlogged data, try to drain it first if (!this->overflow_buf_.empty()) { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { HELPER_LOG("Socket write failed with errno %d", errno); if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; } - // If still backlogged, queue new data behind it - if (!this->overflow_buf_.empty()) - return this->enqueue_or_fail_(iov, iovcnt, total_write_len, skip); } - // No backlog — try to send directly - // Optimize for single iovec case (common for plaintext API) - ssize_t sent = - (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); + // If no backlog (either drained or was empty), try to send directly + if (this->overflow_buf_.empty()) { + // Optimize for single iovec case (common for plaintext API) + ssize_t sent = + (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); - if (sent == -1) { - HELPER_LOG("Socket write failed with errno %d", errno); - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) - return APIError::SOCKET_WRITE_FAILED; - } else if (static_cast(sent) >= total_write_len) { - // All data sent successfully - return APIError::OK; - } else { - // Partially sent — queue the remainder - skip = static_cast(sent); + if (sent == -1) { + HELPER_LOG("Socket write failed with errno %d", errno); + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; + } else if (static_cast(sent) >= total_write_len) { + return APIError::OK; // All data sent successfully + } else { + skip = static_cast(sent); // Partially sent — queue the remainder + } } return this->enqueue_or_fail_(iov, iovcnt, total_write_len, skip); From 5bee336a76e1975d82b99d5ae8798b765003912e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:31:36 -1000 Subject: [PATCH 31/41] Single enqueue_or_fail_ tail call in write_raw_ --- esphome/components/api/api_frame_helper.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 482f3aa0b9..12151ebacf 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -120,10 +120,11 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; } + // If drain didn't fully clear, skip direct send and queue behind backlog } - // If no backlog (either drained or was empty), try to send directly if (this->overflow_buf_.empty()) { + // No backlog — try to send directly // Optimize for single iovec case (common for plaintext API) ssize_t sent = (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); From 2009ec5da8771e948dd4942af2814db9b7fb92d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:33:59 -1000 Subject: [PATCH 32/41] Inline enqueue_or_fail_ at single call site, add branch hints Only one call site remains after tail-call restructure, so inline it. Add [[unlikely]]/[[likely]] hints for overflow and error paths. --- esphome/components/api/api_frame_helper.cpp | 15 ++++++++++----- esphome/components/api/api_frame_helper.h | 9 --------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 12151ebacf..b19556f034 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -114,7 +114,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ uint16_t skip = 0; // If there is already backlogged data, try to drain it first - if (!this->overflow_buf_.empty()) { + if (!this->overflow_buf_.empty()) [[unlikely]] { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { HELPER_LOG("Socket write failed with errno %d", errno); if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) @@ -129,18 +129,23 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ ssize_t sent = (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); - if (sent == -1) { + if (sent == -1) [[unlikely]] { // Socket write failed HELPER_LOG("Socket write failed with errno %d", errno); if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; - } else if (static_cast(sent) >= total_write_len) { - return APIError::OK; // All data sent successfully + } else if (static_cast(sent) >= total_write_len) [[likely]] { // All data sent successfully + return APIError::OK; // All data sent successfully } else { skip = static_cast(sent); // Partially sent — queue the remainder } } - return this->enqueue_or_fail_(iov, iovcnt, total_write_len, skip); + // Queue data into overflow buffer, or fail the connection if full + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; } const char *APIFrameHelper::get_peername_to(std::span buf) const { diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 5709a77854..ccbb9fa077 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -212,15 +212,6 @@ class APIFrameHelper { return APIError::SOCKET_WRITE_FAILED; } - // Enqueue IOV data into the overflow buffer, or fail the connection if full - APIError enqueue_or_fail_(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_len, skip)) { - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; - } - return APIError::OK; - } - // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; From b9a688a946d118e8bd97346fef3da6ceffae18ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:35:08 -1000 Subject: [PATCH 33/41] Clean up write_raw_: collapse error checks, remove redundant comments --- esphome/components/api/api_frame_helper.cpp | 32 +++++++++------------ 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index b19556f034..35c0fc8a08 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -100,11 +100,10 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("UNKNOWN"); } -// This method writes data to socket or buffers it +// Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. +// Returns OK if all data was sent or successfully queued. +// Returns SOCKET_WRITE_FAILED on hard error (sets state to FAILED). APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { - // Returns APIError::OK if all data was sent or successfully queued. - // Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED. - #ifdef HELPER_LOG_PACKETS for (int i = 0; i < iovcnt; i++) { LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); @@ -113,34 +112,29 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ uint16_t skip = 0; - // If there is already backlogged data, try to drain it first + // Drain any existing backlog first if (!this->overflow_buf_.empty()) [[unlikely]] { - if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - HELPER_LOG("Socket write failed with errno %d", errno); - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) - return APIError::SOCKET_WRITE_FAILED; - } - // If drain didn't fully clear, skip direct send and queue behind backlog + if (this->overflow_buf_.try_drain(this->socket_.get()) == -1 && + this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; } + // If backlog is clear, try direct send if (this->overflow_buf_.empty()) { - // No backlog — try to send directly - // Optimize for single iovec case (common for plaintext API) ssize_t sent = (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); - if (sent == -1) [[unlikely]] { // Socket write failed - HELPER_LOG("Socket write failed with errno %d", errno); + if (sent == -1) [[unlikely]] { if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; - } else if (static_cast(sent) >= total_write_len) [[likely]] { // All data sent successfully - return APIError::OK; // All data sent successfully + } else if (static_cast(sent) >= total_write_len) [[likely]] { + return APIError::OK; } else { - skip = static_cast(sent); // Partially sent — queue the remainder + skip = static_cast(sent); } } - // Queue data into overflow buffer, or fail the connection if full + // Queue unsent data into overflow buffer if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; From 848b1a303d631492a3e4eec00f0e04ee7a4671b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:36:24 -1000 Subject: [PATCH 34/41] Restore HELPER_LOG in write_raw_ error paths --- esphome/components/api/api_frame_helper.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 35c0fc8a08..88f308542b 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -114,9 +114,11 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // Drain any existing backlog first if (!this->overflow_buf_.empty()) [[unlikely]] { - if (this->overflow_buf_.try_drain(this->socket_.get()) == -1 && - this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) - return APIError::SOCKET_WRITE_FAILED; + if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { + HELPER_LOG("Socket write failed with errno %d", errno); + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; + } } // If backlog is clear, try direct send @@ -125,6 +127,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) [[unlikely]] { + HELPER_LOG("Socket write failed with errno %d", errno); if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) return APIError::SOCKET_WRITE_FAILED; } else if (static_cast(sent) >= total_write_len) [[likely]] { From ad427e163526b0f4c31b5e55f15ffa24c1cde7d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:36:52 -1000 Subject: [PATCH 35/41] tune --- esphome/components/api/api_frame_helper.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 88f308542b..d89154f457 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -122,7 +122,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } // If backlog is clear, try direct send - if (this->overflow_buf_.empty()) { + if (this->overflow_buf_.empty()) [[likely]] { ssize_t sent = (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); From 9f774b382701c5276ba4d4271d24f28693b9fe3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:43:17 -1000 Subject: [PATCH 36/41] Inline empty() fast path, out-of-line drain+error handling Keep the cheap overflow_buf_.empty() check inline at all call sites. Move try_drain + HELPER_LOG + error handling into a single out-of-line drain_overflow_and_handle_errors_() used by both write_raw_ and the subclass loop() methods. Eliminates the duplicated drain logic. --- esphome/components/api/api_frame_helper.cpp | 17 ++++++++++++----- esphome/components/api/api_frame_helper.h | 14 +++++--------- .../components/api/api_frame_helper_noise.cpp | 9 +++++---- .../api/api_frame_helper_plaintext.cpp | 9 +++++---- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index d89154f457..ec326edbd6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -100,6 +100,15 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("UNKNOWN"); } +APIError APIFrameHelper::drain_overflow_and_handle_errors_() { + if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { + HELPER_LOG("Socket write failed with errno %d", errno); + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + return APIError::SOCKET_WRITE_FAILED; + } + return APIError::OK; +} + // Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. // Returns OK if all data was sent or successfully queued. // Returns SOCKET_WRITE_FAILED on hard error (sets state to FAILED). @@ -114,11 +123,9 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // Drain any existing backlog first if (!this->overflow_buf_.empty()) [[unlikely]] { - if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - HELPER_LOG("Socket write failed with errno %d", errno); - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) - return APIError::SOCKET_WRITE_FAILED; - } + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; } // If backlog is clear, try direct send diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ccbb9fa077..72ccf8aa56 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -190,15 +190,11 @@ class APIFrameHelper { } protected: - // Drain any backlogged overflow data to the socket. - // Returns OK even for WOULD_BLOCK to avoid connection termination. - APIError try_drain_overflow_buffer_() { - if (!this->overflow_buf_.empty() && this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) - return APIError::SOCKET_WRITE_FAILED; - } - return APIError::OK; - } + // Drain backlogged overflow data to the socket and handle errors. + // Called when overflow_buf_.empty() is false. Out-of-line to keep the + // fast path (empty check) inline at call sites. + // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. + APIError drain_overflow_and_handle_errors_(); // Common implementation for writing raw data to socket APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 9690266971..d73b1147c5 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -153,11 +153,12 @@ APIError APINoiseFrameHelper::loop() { } } - APIError err = this->try_drain_overflow_buffer_(); - if (err != APIError::OK) { - HELPER_LOG("Overflow drain failed with errno %d", errno); + if (!this->overflow_buf_.empty()) [[unlikely]] { + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; } - return err; + return APIError::OK; } /** Read a packet into the rx_buf_. diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 1790d50727..1ed1b9c8e3 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -64,11 +64,12 @@ APIError APIPlaintextFrameHelper::loop() { if (state_ != State::DATA) { return APIError::BAD_STATE; } - APIError err = this->try_drain_overflow_buffer_(); - if (err != APIError::OK) { - HELPER_LOG("Overflow drain failed with errno %d", errno); + if (!this->overflow_buf_.empty()) [[unlikely]] { + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; } - return err; + return APIError::OK; } /** Read a packet into the rx_buf_. From e2a68cc2d37ac425b692278748c677a21c3245e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 12:44:33 -1000 Subject: [PATCH 37/41] Simplify: return drain result directly in loop() --- esphome/components/api/api_frame_helper_noise.cpp | 4 +--- esphome/components/api/api_frame_helper_plaintext.cpp | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index d73b1147c5..78e87793fc 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -154,9 +154,7 @@ APIError APINoiseFrameHelper::loop() { } if (!this->overflow_buf_.empty()) [[unlikely]] { - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; + return this->drain_overflow_and_handle_errors_(); } return APIError::OK; } diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 1ed1b9c8e3..9e669b31ee 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -65,9 +65,7 @@ APIError APIPlaintextFrameHelper::loop() { return APIError::BAD_STATE; } if (!this->overflow_buf_.empty()) [[unlikely]] { - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; + return this->drain_overflow_and_handle_errors_(); } return APIError::OK; } From 3ac6eb0f5a95b54720dd6abc9707c27198b31601 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 13:01:42 -1000 Subject: [PATCH 38/41] Address review: only log hard errors, fix try_drain doc comments - Move HELPER_LOG after errno check so transient WOULD_BLOCK doesn't spam very-verbose logs during normal backpressure - Fix try_drain() docstring: -1 can be EWOULDBLOCK or hard error (caller distinguishes via errno), 0 means no progress (not would-block) --- esphome/components/api/api_frame_helper.cpp | 10 ++++++---- esphome/components/api/api_overflow_buffer.cpp | 3 ++- esphome/components/api/api_overflow_buffer.h | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index ec326edbd6..bdc8b1be06 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -102,9 +102,10 @@ const LogString *api_error_to_logstr(APIError err) { APIError APIFrameHelper::drain_overflow_and_handle_errors_() { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - HELPER_LOG("Socket write failed with errno %d", errno); - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) { + HELPER_LOG("Socket write failed with errno %d", errno); return APIError::SOCKET_WRITE_FAILED; + } } return APIError::OK; } @@ -134,9 +135,10 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) [[unlikely]] { - HELPER_LOG("Socket write failed with errno %d", errno); - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) + if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) { + HELPER_LOG("Socket write failed with errno %d", errno); return APIError::SOCKET_WRITE_FAILED; + } } else if (static_cast(sent) >= total_write_len) [[likely]] { return APIError::OK; } else { diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index af3b670e0e..e242d4553e 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -18,7 +18,8 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { ssize_t sent = socket->write(front->current_data(), front->remaining()); if (sent <= 0) { - // -1 = error (caller checks errno), 0 = would block + // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) + // 0 = nothing sent (treat as no progress) return sent; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index cd26bcdb8b..1bea871f57 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -52,7 +52,8 @@ class APIOverflowBuffer { uint8_t count() const { return this->count_; } /// Try to drain queued data to the socket. - /// Returns bytes-written >= 0 on success/partial, -1 on hard error (errno set). + /// Returns bytes-written > 0 on success/partial, 0 if all drained, + /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). /// Frees entries as they are fully sent. ssize_t try_drain(socket::Socket *socket); From 69956c54a724387e134177ee3f1107eef54a6614 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 13:04:29 -1000 Subject: [PATCH 39/41] fix power of 2 --- esphome/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index aabe708a9e..ff11222276 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -306,7 +306,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast esp32=8, # More RAM, can buffer more - rp2040=5, # Limited RAM + rp2040=8, # Moderate RAM bk72xx=8, # Moderate RAM nrf52=8, # Moderate RAM rtl87xx=8, # Moderate RAM From 61cc3022cb5ec943fd66c23f7928744731eb009b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 13:08:08 -1000 Subject: [PATCH 40/41] Address review: save errno before use, fix docs, add missing include - Save errno into local before check_socket_write_err_ and HELPER_LOG to avoid potential clobbering between reads - Update try_drain() doc: 0 means all-drained or no-progress (callers only act on -1) - Include socket/headers.h explicitly for struct iovec --- esphome/components/api/api_frame_helper.cpp | 10 ++++++---- esphome/components/api/api_overflow_buffer.h | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index bdc8b1be06..55ee06c41b 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -102,8 +102,9 @@ const LogString *api_error_to_logstr(APIError err) { APIError APIFrameHelper::drain_overflow_and_handle_errors_() { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) { - HELPER_LOG("Socket write failed with errno %d", errno); + int err = errno; + if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } } @@ -135,8 +136,9 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) [[unlikely]] { - if (this->check_socket_write_err_(errno) != APIError::WOULD_BLOCK) { - HELPER_LOG("Socket write failed with errno %d", errno); + int err = errno; + if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } } else if (static_cast(sent) >= total_write_len) [[likely]] { diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 1bea871f57..19aae680f0 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -6,6 +6,7 @@ #include "esphome/core/defines.h" #ifdef USE_API +#include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" #include "esphome/core/helpers.h" @@ -52,8 +53,9 @@ class APIOverflowBuffer { uint8_t count() const { return this->count_; } /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained, + /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). + /// Callers only need to act on -1; 0 and positive values both mean "no error". /// Frees entries as they are fully sent. ssize_t try_drain(socket::Socket *socket); From 1ecbd28742543c2d72cfc678df16c70662b17d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 13:09:41 -1000 Subject: [PATCH 41/41] Address review: restore queue-full log, fix power-of-2 comment - Add HELPER_LOG when overflow buffer is full and connection is dropped - Clarify comment: defaults are power of 2 (not a requirement for users) --- esphome/components/api/__init__.py | 2 +- esphome/components/api/api_frame_helper.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ff11222276..4c3cf81927 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -301,7 +301,7 @@ CONFIG_SCHEMA = cv.All( # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE should be a power 2 for best performance + # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 55ee06c41b..6d3bd51b58 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -150,6 +150,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // Queue unsent data into overflow buffer if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { + HELPER_LOG("Overflow buffer full, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; }