From fe6ecb24b4bfc16f096837e39a74664c97e382de Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 13 Apr 2026 13:49:13 +0200 Subject: [PATCH 01/19] [bme68x_bsec2] use esphome-libs wrappers for ESP32 (#15697) --- esphome/components/bme68x_bsec2/__init__.py | 36 +++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5f0afa9c9f..b63443c5f3 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -17,6 +17,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] DOMAIN = "bme68x_bsec2" BSEC2_LIBRARY_VERSION = "1.10.2610" +BME68x_LIBRARY_VERSION = "v1.3.40408" CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" @@ -184,16 +185,31 @@ async def to_code_base(config): if core.CORE.using_arduino: cg.add_library("Wire", None) cg.add_library("SPI", None) - cg.add_library( - "BME68x Sensor library", - None, - "https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408", - ) - cg.add_library( - "BSEC2 Software Library", - None, - f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", - ) + + if core.CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + add_idf_component( + name="boschsensortec/Bosch-BME68x-Library", + repo="https://github.com/esphome-libs/Bosch-BME68x-Library", + ref=BME68x_LIBRARY_VERSION, + ) + add_idf_component( + name="boschsensortec/Bosch-BSEC2-Library", + repo="https://github.com/esphome-libs/Bosch-BSEC2-Library", + ref=BSEC2_LIBRARY_VERSION, + ) + else: + cg.add_library( + "BME68x Sensor library", + None, + f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}", + ) + cg.add_library( + "BSEC2 Software Library", + None, + f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", + ) cg.add_define("USE_BSEC2") From 7918a93a7feeb37f8cb957444e326e4e9867f970 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 13 Apr 2026 15:40:49 +0200 Subject: [PATCH 02/19] [esp32] Fix some compiler warnings & bugs (#15610) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.h | 4 ++-- esphome/components/midea/air_conditioner.cpp | 3 ++- esphome/components/mipi_spi/mipi_spi.h | 8 ++++---- esphome/components/tcs34725/tcs34725.cpp | 2 +- esphome/cpp_helpers.py | 3 ++- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c44a11e3ed..95154812cb 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent, #endif float get_reference_voltage(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage + return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage #else return 120.0; // Default voltage #endif } float get_reference_current(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current + return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current #else return 5.0f; // Default current #endif diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 50521cf238..69e0d46d2d 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -28,7 +28,8 @@ void AirConditioner::on_status_change() { if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { // Read existing presets (set by codegen), append frost protection, write back - const auto &existing = this->get_traits().get_supported_custom_presets(); + auto traits = this->get_traits(); + const auto &existing = traits.get_supported_custom_presets(); bool found = false; for (const char *p : existing) { if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 423226b1d7..2242be6c17 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -234,9 +234,9 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, - this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, - this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, + internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, + (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, + this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } @@ -305,7 +305,7 @@ class MipiSpi : public display::Display, this->write_command_(BRIGHTNESS, this->brightness_.value()); // calculate new madctl value from base value adjusted for rotation - uint8_t madctl = MADCTL; // lower 8 bits only + uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0; constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX; constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY; diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 4fe87de0ca..1098d8de5f 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration my_integration_time_regval = integration_time; this->integration_time_auto_ = false; } - this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f; + this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f; ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_); } void TCS34725Component::set_gain(TCS34725Gain gain) { diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 479090016f..f2bd3b92a3 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -113,7 +113,8 @@ def _generate_source_table_code( entries = ", ".join(var_names) lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{") - lines.append(f' if (index == 0 || index > {count}) return LOG_STR("");') + cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}" + lines.append(f' if ({cond}) return LOG_STR("");') lines.append(" return reinterpret_cast(") lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));") lines.append("}") From 6aa538a61d7559531b446763abae20eda918572b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 13 Apr 2026 15:42:02 +0200 Subject: [PATCH 03/19] [micro_wake_word] Bugfix: Use es-nn v1.1.2 (last known working version) (#15703) --- esphome/components/micro_wake_word/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index de95e4961b..5ab1e4bb80 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -452,7 +452,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) - esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1") + esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") From f30f0a0edcbc7ed89fff03f10b14f90326d0bbab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 03:43:17 -1000 Subject: [PATCH 04/19] [zephyr] Remove redundant yield() from main loop (#15694) --- esphome/components/zephyr/core.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index a3b0471ebc..93a9a1ae8e 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -99,7 +99,6 @@ int main() { setup(); while (true) { loop(); - esphome::yield(); } return 0; } From 5d0cfc31fa92c1038b328419f61d33f1eaadbe68 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:18:44 -0400 Subject: [PATCH 05/19] [core] Move FILTER_PLATFORMIO_LINES into platformio_runner (#15707) --- esphome/platformio_api.py | 39 --------------------- esphome/platformio_runner.py | 45 ++++++++++++++++++++++++- tests/unit_tests/test_platformio_api.py | 2 +- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index fc21977fdd..dec541985f 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -14,45 +14,6 @@ from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) -IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" -FILTER_PLATFORMIO_LINES = [ - r"Verbose mode can be enabled via `-v, --verbose` option.*", - r"CONFIGURATION: https://docs.platformio.org/.*", - r"DEBUG: Current.*", - r"LDF Modes:.*", - r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", - f"Looking for {IGNORE_LIB_WARNINGS} library in registry", - f"Warning! Library `.*'{IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", - f"You can ignore this message, if `.*{IGNORE_LIB_WARNINGS}.*` is a built-in library.*", - r"Scanning dependencies...", - r"Found \d+ compatible libraries", - r"Memory Usage -> https://bit.ly/pio-memory-usage", - r"Found: https://platformio.org/lib/show/.*", - r"Using cache: .*", - r"Installing dependencies", - r"Library Manager: Already installed, built-in library", - r"Building in .* mode", - r"Advanced Memory Usage is available via .*", - r"Merged .* ELF section", - r"esptool.py v.*", - r"esptool v.*", - r"Checking size .*", - r"Retrieving maximum program size .*", - r"PLATFORM: .*", - r"PACKAGES:.*", - r" - framework-arduinoespressif.* \(.*\)", - r" - tool-esptool.* \(.*\)", - r" - toolchain-.* \(.*\)", - r"Creating BIN file .*", - r"Warning! Could not find file \".*.crt\"", - r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", - r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", - r"Warning: esp-idf-size exited with code 2", - r"esp_idf_size: error: unrecognized arguments: --ng", - r"Package configuration completed successfully", -] - - def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py index 92700d5c42..599c9408a4 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio_runner.py @@ -101,6 +101,50 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" +# Regex patterns matched against each line of PlatformIO output. Lines that +# match are dropped by RedirectText before they reach the parent process. +# Patterns are anchored at the start of the line (RedirectText uses +# ``re.match``). Disabled when the user passes ``-v`` / ``--verbose`` to +# ``esphome compile``. +FILTER_PLATFORMIO_LINES = [ + r"Verbose mode can be enabled via `-v, --verbose` option.*", + r"CONFIGURATION: https://docs.platformio.org/.*", + r"DEBUG: Current.*", + r"LDF Modes:.*", + r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", + f"Looking for {_IGNORE_LIB_WARNINGS} library in registry", + f"Warning! Library `.*'{_IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", + f"You can ignore this message, if `.*{_IGNORE_LIB_WARNINGS}.*` is a built-in library.*", + r"Scanning dependencies...", + r"Found \d+ compatible libraries", + r"Memory Usage -> https://bit.ly/pio-memory-usage", + r"Found: https://platformio.org/lib/show/.*", + r"Using cache: .*", + r"Installing dependencies", + r"Library Manager: Already installed, built-in library", + r"Building in .* mode", + r"Advanced Memory Usage is available via .*", + r"Merged .* ELF section", + r"esptool.py v.*", + r"esptool v.*", + r"Checking size .*", + r"Retrieving maximum program size .*", + r"PLATFORM: .*", + r"PACKAGES:.*", + r" - framework-arduinoespressif.* \(.*\)", + r" - tool-esptool.* \(.*\)", + r" - toolchain-.* \(.*\)", + r"Creating BIN file .*", + r"Warning! Could not find file \".*.crt\"", + r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", + r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", + r"Warning: esp-idf-size exited with code 2", + r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", +] + + def main() -> int: patch_structhash() patch_file_downloader() @@ -126,7 +170,6 @@ def main() -> int: # Filtering is disabled when the user passed -v / --verbose to # ``esphome compile``, preserving the previous in-process behavior where # verbose mode let all PlatformIO output through unfiltered. - from esphome.platformio_api import FILTER_PLATFORMIO_LINES from esphome.util import RedirectText is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index ddc4e45c84..67e64e5f61 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -906,7 +906,7 @@ def _filter_through_redirect(line: str) -> str: captured = io.StringIO() redirect = RedirectText( - captured, filter_lines=platformio_api.FILTER_PLATFORMIO_LINES + captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES ) redirect.write(line + "\n") return captured.getvalue() From fb0283e0ee04830275fe6e857f4a8955ac137c7b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:18:52 -0400 Subject: [PATCH 06/19] [esp32] Update the recommended platform to 55.03.38-1 (#15705) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 8 ++++---- platformio.ini | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index cd61d9ec48..60c3776aa8 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d48687d988ae2a94a9973226df773478a7db1d52133545f07aa05e34fc678dcf +10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cd38c82dd8..2974028b50 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -676,7 +676,7 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { - cv.Version(3, 3, 8): cv.Version(55, 3, 38), + cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), cv.Version(3, 3, 5): cv.Version(55, 3, 35), @@ -724,7 +724,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 4): cv.Version(55, 3, 38), + cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 38), - "latest": cv.Version(55, 3, 38), + "recommended": cv.Version(55, 3, 38, "1"), + "latest": cv.Version(55, 3, 38, "1"), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/platformio.ini b/platformio.ini index 7d17628a8f..708d62afdc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -133,7 +133,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz @@ -169,7 +169,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz From 53ce2a2f7fed1a142f43eec73c681c0106d14552 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:25:05 -1000 Subject: [PATCH 07/19] [api] Add speed_optimized to SubscribeLogsResponse (#15698) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api.proto | 5 +++-- esphome/components/api/api_pb2.cpp | 18 ++++++++++++------ script/api_protobuf/api_protobuf.py | 6 ++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4a9a6f9051..f906cfb8d7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -778,9 +778,10 @@ message SubscribeLogsResponse { option (source) = SOURCE_SERVER; option (log) = false; option (no_delay) = false; + option (speed_optimized) = true; - LogLevel level = 1; - bytes message = 3; + LogLevel level = 1 [(force) = true]; + bytes message = 3 [(force) = true]; } // ==================== NOISE ENCRYPTION ==================== diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 481510e407..f304c85282 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -916,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t } return true; } -uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); return pos; } -uint32_t SubscribeLogsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; - size += this->level ? 2 : 0; - size += ProtoSize::calc_length(1, this->message_len_); + size += 2; + size += ProtoSize::calc_length_force(1, this->message_len_); return size; } #ifdef USE_API_NOISE diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 1743d2bb34..73e0859d5e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1028,7 +1028,8 @@ class BytesType(TypeInfo): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -1109,7 +1110,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);" class PointerToStringBufferType(PointerToBufferTypeBase): From af7cb1d81ecefbdc8240a03f05ac926c1d21a96b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:40:25 -1000 Subject: [PATCH 08/19] [scheduler] Force-inline process_defer_queue_() fast path (#15686) --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049..ea6c2a675e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -407,7 +407,7 @@ class Scheduler { // Process defer queue for FIFO execution of deferred items. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing deferred) is just an atomic load check. - inline void HOT process_defer_queue_(uint32_t &now) { + inline void ESPHOME_ALWAYS_INLINE HOT process_defer_queue_(uint32_t &now) { // Fast path: nothing to process, avoid lock entirely. // Worst case is a one-loop-iteration delay before newly deferred items are processed. if (this->defer_empty_()) From 9f7e310526cb51126aaef85e64f76b8c152e620c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:40:39 -1000 Subject: [PATCH 09/19] [scheduler] Force-inline cleanup_() fast path (#15683) --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ea6c2a675e..e678ea0987 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -302,7 +302,7 @@ class Scheduler { // loop thread structurally modifies items_ (push/pop/erase). Other threads may // iterate items_ and mark items removed under lock_, but never change the // vector's size or data pointer. - inline bool HOT cleanup_() { + inline bool ESPHOME_ALWAYS_INLINE HOT cleanup_() { if (this->to_remove_empty_()) return !this->items_.empty(); return this->cleanup_slow_path_(); From b85a7ef3177d2fe171922e2f67ad3056b4b3f34f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:40:58 -1000 Subject: [PATCH 10/19] [scheduler] Force-inline process_to_add() fast path (#15685) --- esphome/core/scheduler.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index e678ea0987..21af94ea4e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -138,7 +138,7 @@ class Scheduler { // (single-threaded). This is safe because the main loop is the only thread // that reads to_add_ without holding lock_; other threads may read it only // while holding the mutex (e.g. cancel_item_locked_). - inline void HOT process_to_add() { + inline void ESPHOME_ALWAYS_INLINE HOT process_to_add() { if (this->to_add_empty_()) return; this->process_to_add_slow_path_(); From e8bc4bedb4ebe191bb10a6f125979002d0935a14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:11 -1000 Subject: [PATCH 11/19] Bump actions/cache from 5.0.4 to 5.0.5 in /.github/actions/restore-python (#15714) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index af54175c01..21393f2aba 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length From ce6bffb65cba8f3a9d954b00b2d4e77049343df0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:24 -1000 Subject: [PATCH 12/19] Bump actions/cache from 5.0.4 to 5.0.5 (#15713) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2240879bd2..6aa5b2a547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length @@ -159,7 +159,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -198,7 +198,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -231,7 +231,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -253,7 +253,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -387,14 +387,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -466,14 +466,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -555,14 +555,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -817,7 +817,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -841,7 +841,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -883,7 +883,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -930,7 +930,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 40348092818c1df90de5a7795b45787dce8e36c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:46 -1000 Subject: [PATCH 13/19] Bump actions/create-github-app-token from 3.0.0 to 3.1.1 (#15712) 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 f5214a5ce0..0e5ceb9346 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@f8d387b68d61c58ab83c6c016672934102569859 # v2 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # 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 c92581b49b..35b9e065e1 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@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 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@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 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@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 8cdffef82a44ec58c43a290ca8b22f9e55071b8c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:06:56 -0400 Subject: [PATCH 14/19] [heatpumpir] Bump tonia/HeatpumpIR to 1.0.41 (#15711) --- .clang-tidy.hash | 2 +- esphome/components/heatpumpir/climate.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 60c3776aa8..ab526134f8 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90 +dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815 diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 7743da77ab..b7e0437480 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -127,6 +127,6 @@ async def to_code(config): cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_library("tonia/HeatpumpIR", "1.0.40") + cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/platformio.ini b/platformio.ini index 708d62afdc..3897db83e1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -83,7 +83,7 @@ lib_deps = fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -178,7 +178,7 @@ lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.4 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare From 81a2caee36e2ad3d774b020141ae70cdcf870107 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:41:12 -1000 Subject: [PATCH 15/19] [web_server] Reset OTA backend on new upload to avoid bricked device after interrupted OTA If a captive_portal or web_server OTA upload is interrupted mid-stream (e.g. TCP connection reset), the shared OTARequestHandler's ota_backend_ is left open. When the browser resends the multipart POST, the guard 'index == 0 && !ota_backend_' skipped re-initialization, so new bytes were written at the previous session's offset. The Updater's end() then reports success with a concatenated image in flash, bricking the device on reboot. Abort and reset any in-progress backend whenever a new multipart upload starts (index == 0) so the fresh upload begins a clean OTA session. --- .../components/web_server/ota/ota_web_server.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95b166901a..d5c6f9e7a4 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -114,7 +114,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && !this->ota_backend_) { + if (index == 0) { + // A new multipart upload is starting. If a previous upload was interrupted + // (e.g. TCP reset) the backend from that session may still be open; tear it + // down so flash state doesn't get concatenated with the new image (which + // can produce a technically-valid-sized but corrupted firmware that + // bricks the device once it reboots). + if (this->ota_backend_) { + ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); + this->ota_backend_->abort(); + this->ota_backend_.reset(); + } + // Initialize OTA on first call this->ota_init_(filename.c_str()); From ccdb5a3bfb3161eca67f7782f576a70882d502e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:49:11 -1000 Subject: [PATCH 16/19] Track request pointer to avoid re-init on web_server_idf's double index==0 call web_server_idf invokes handleUpload twice with index==0 at the start of every upload (once as a 'Start' signal with nullptr data, then with the first data chunk). The previous fix treated the second call as a stale-session retry, wastefully aborting and re-initializing the backend on every single upload. Store the AsyncWebServerRequest pointer that owns the current session and only tear down the backend when a different request arrives at index==0. Clear the tracked pointer on every backend reset path so a stack-reused request pointer from a later upload cannot collide. --- .../components/web_server/ota/ota_web_server.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index d5c6f9e7a4..747bbca544 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -72,6 +72,11 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + // Tracks the request that owns the current ota_backend_ session so we can + // detect when a new (retry) request arrives while a previous session is + // still open -- without re-triggering on the multiple index==0 callbacks + // web_server_idf makes for a single upload (Start + first data chunk). + AsyncWebServerRequest *ota_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -114,8 +119,8 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0) { - // A new multipart upload is starting. If a previous upload was interrupted + if (index == 0 && this->ota_request_ != request) { + // A new request is starting an upload. If a previous upload was interrupted // (e.g. TCP reset) the backend from that session may still be open; tear it // down so flash state doesn't get concatenated with the new image (which // can produce a technically-valid-sized but corrupted firmware that @@ -125,6 +130,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->ota_backend_->abort(); this->ota_backend_.reset(); } + this->ota_request_ = request; // Initialize OTA on first call this->ota_init_(filename.c_str()); @@ -160,6 +166,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -178,6 +185,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -210,6 +218,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } From 3042a910fe348dd5a7c2b2ae08f06f137a44ff93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:49:44 -1000 Subject: [PATCH 17/19] Extract ota_end_session_ helper to DRY backend reset paths --- .../components/web_server/ota/ota_web_server.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 747bbca544..475ae5492b 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -64,6 +64,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + void ota_end_session_() { + this->ota_backend_.reset(); + this->ota_request_ = nullptr; + } uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -128,7 +132,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (this->ota_backend_) { ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); this->ota_backend_->abort(); - this->ota_backend_.reset(); + this->ota_end_session_(); } this->ota_request_ = request; @@ -165,8 +169,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf error_code = this->ota_backend_->begin(0); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); - this->ota_backend_.reset(); - this->ota_request_ = nullptr; + this->ota_end_session_(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -184,8 +187,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); - this->ota_backend_.reset(); - this->ota_request_ = nullptr; + this->ota_end_session_(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -217,8 +219,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } - this->ota_backend_.reset(); - this->ota_request_ = nullptr; + this->ota_end_session_(); } } From ba2558edf64e82fcbd667ba31c04677c6a189693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 12:56:52 -1000 Subject: [PATCH 18/19] Gate OTA session init on index==0 && len>0 instead of request pointer The previous fix tracked the AsyncWebServerRequest pointer to distinguish web_server_idf's double index==0 callbacks (Start marker with data==nullptr, then the first real data chunk) from a retry after an interrupted upload. That is unreliable: AsyncWebServerRequest is a stack-allocated object in the httpd task, so a new request that happens to reuse the same stack address as an interrupted one compares equal and silently skips the abort. Closing a browser tab mid-upload and starting a fresh upload from another window could then concatenate partial data from the first upload with the new image. Gate the init block on 'index == 0 && len > 0' instead. This uniquely identifies the first real byte of an upload on both IDF (start-marker has len==0) and Arduino, no identity tracking needed. --- .../web_server/ota/ota_web_server.cpp | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 475ae5492b..4edab37603 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -64,10 +64,6 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); - void ota_end_session_() { - this->ota_backend_.reset(); - this->ota_request_ = nullptr; - } uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -76,11 +72,6 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; - // Tracks the request that owns the current ota_backend_ session so we can - // detect when a new (retry) request arrives while a previous session is - // still open -- without re-triggering on the multiple index==0 callbacks - // web_server_idf makes for a single upload (Start + first data chunk). - AsyncWebServerRequest *ota_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -123,18 +114,20 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && this->ota_request_ != request) { - // A new request is starting an upload. If a previous upload was interrupted - // (e.g. TCP reset) the backend from that session may still be open; tear it - // down so flash state doesn't get concatenated with the new image (which - // can produce a technically-valid-sized but corrupted firmware that - // bricks the device once it reboots). + // First byte of a new upload: index==0 with actual data. (web_server_idf + // fires a separate start-marker call with data==nullptr/len==0 before the + // first real chunk; gate on len>0 so we only trigger once per upload.) + if (index == 0 && len > 0) { + // If a previous upload was interrupted (e.g. client closed the tab, TCP + // reset) the backend from that session may still be open. Tear it down + // so flash state doesn't get concatenated with the new image (which can + // produce a technically-valid-sized but corrupted firmware that bricks + // the device once it reboots). if (this->ota_backend_) { ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); this->ota_backend_->abort(); - this->ota_end_session_(); + this->ota_backend_.reset(); } - this->ota_request_ = request; // Initialize OTA on first call this->ota_init_(filename.c_str()); @@ -169,7 +162,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf error_code = this->ota_backend_->begin(0); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); - this->ota_end_session_(); + this->ota_backend_.reset(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -187,7 +180,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); - this->ota_end_session_(); + this->ota_backend_.reset(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -219,7 +212,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } - this->ota_end_session_(); + this->ota_backend_.reset(); } } From c26d1803253fc1f7b3ce720a0769d1d8e4773c37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:07:50 -1000 Subject: [PATCH 19/19] Emit OTA_ABORT state when tearing down a stale session on retry Addresses Copilot review suggestion on #15720: when the handler aborts a previously interrupted OTA session because a new upload arrived, also fire the OTA_ABORT state notification so user-facing on_abort: automations and other OTA state listeners observe the teardown. Without this, the abort would be silent to listeners (distinct from OTA_ERROR, which signals a failed session rather than a superseded one). --- esphome/components/web_server/ota/ota_web_server.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 4edab37603..9812714ec0 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -126,6 +126,10 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (this->ota_backend_) { ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + // Notify listeners that the previous session was aborted before the new one starts. + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif this->ota_backend_.reset(); }