From 6a096c1d5a734ed3f9db5f70c3fecdaec55dc777 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 2 Jul 2025 13:36:15 +1200 Subject: [PATCH 01/17] [api] Dump bytes fields as hex instead of unreadable string (#9288) --- esphome/components/api/api_pb2.cpp | 23 ++++++++++++----------- esphome/core/helpers.cpp | 21 ++++++++++++++++++--- esphome/core/helpers.h | 2 ++ script/api_protobuf/api_protobuf.py | 3 ++- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 9793565ee5..8bce14c9cc 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3,6 +3,7 @@ #include "api_pb2.h" #include "api_pb2_size.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include @@ -3510,7 +3511,7 @@ void SubscribeLogsResponse::dump_to(std::string &out) const { out.append("\n"); out.append(" message: "); - out.append("'").append(this->message).append("'"); + out.append(format_hex_pretty(this->message)); out.append("\n"); out.append(" send_failed: "); @@ -3538,7 +3539,7 @@ void NoiseEncryptionSetKeyRequest::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; out.append("NoiseEncryptionSetKeyRequest {\n"); out.append(" key: "); - out.append("'").append(this->key).append("'"); + out.append(format_hex_pretty(this->key)); out.append("\n"); out.append("}"); } @@ -4284,7 +4285,7 @@ void CameraImageResponse::dump_to(std::string &out) const { out.append("\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append(" done: "); @@ -6811,7 +6812,7 @@ void BluetoothServiceData::dump_to(std::string &out) const { } out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append("}"); } @@ -6894,7 +6895,7 @@ void BluetoothLEAdvertisementResponse::dump_to(std::string &out) const { out.append("\n"); out.append(" name: "); - out.append("'").append(this->name).append("'"); + out.append(format_hex_pretty(this->name)); out.append("\n"); out.append(" rssi: "); @@ -6987,7 +6988,7 @@ void BluetoothLERawAdvertisement::dump_to(std::string &out) const { out.append("\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append("}"); } @@ -7514,7 +7515,7 @@ void BluetoothGATTReadResponse::dump_to(std::string &out) const { out.append("\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append("}"); } @@ -7578,7 +7579,7 @@ void BluetoothGATTWriteRequest::dump_to(std::string &out) const { out.append("\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append("}"); } @@ -7670,7 +7671,7 @@ void BluetoothGATTWriteDescriptorRequest::dump_to(std::string &out) const { out.append("\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append("}"); } @@ -7772,7 +7773,7 @@ void BluetoothGATTNotifyDataResponse::dump_to(std::string &out) const { out.append("\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append("}"); } @@ -8492,7 +8493,7 @@ void VoiceAssistantAudio::dump_to(std::string &out) const { __attribute__((unused)) char buffer[64]; out.append("VoiceAssistantAudio {\n"); out.append(" data: "); - out.append("'").append(this->data).append("'"); + out.append(format_hex_pretty(this->data)); out.append("\n"); out.append(" end: "); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index fc91d83972..b4923c7af0 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -4,13 +4,13 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include #include #include #include #include #include #include -#include #ifdef USE_HOST #ifndef _WIN32 @@ -43,10 +43,10 @@ #include #endif #ifdef USE_ESP32 -#include "rom/crc.h" -#include "esp_mac.h" #include "esp_efuse.h" #include "esp_efuse_table.h" +#include "esp_mac.h" +#include "rom/crc.h" #endif #ifdef USE_LIBRETINY @@ -393,6 +393,21 @@ std::string format_hex_pretty(const uint16_t *data, size_t length) { return ret; } std::string format_hex_pretty(const std::vector &data) { return format_hex_pretty(data.data(), data.size()); } +std::string format_hex_pretty(const std::string &data) { + if (data.empty()) + return ""; + std::string ret; + ret.resize(3 * data.length() - 1); + for (size_t i = 0; i < data.length(); i++) { + ret[3 * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[3 * i + 1] = format_hex_pretty_char(data[i] & 0x0F); + if (i != data.length() - 1) + ret[3 * i + 2] = '.'; + } + if (data.length() > 4) + return ret + " (" + std::to_string(data.length()) + ")"; + return ret; +} std::string format_bin(const uint8_t *data, size_t length) { std::string result; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 7d5366f323..362f3d1fa4 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -348,6 +348,8 @@ std::string format_hex_pretty(const uint16_t *data, size_t length); std::string format_hex_pretty(const std::vector &data); /// Format the vector \p data in pretty-printed, human-readable hex. std::string format_hex_pretty(const std::vector &data); +/// Format the string \p data in pretty-printed, human-readable hex. +std::string format_hex_pretty(const std::string &data); /// Format an unsigned integer in pretty-printed, human-readable hex, starting with the most significant byte. template::value, int> = 0> std::string format_hex_pretty(T val) { val = convert_big_endian(val); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index ad8e41ba5e..615f5bbfda 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -530,7 +530,7 @@ class BytesType(TypeInfo): wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2 def dump(self, name: str) -> str: - o = f'out.append("\'").append({name}).append("\'");' + o = f"out.append(format_hex_pretty({name}));" return o def get_size_calculation(self, name: str, force: bool = False) -> str: @@ -1255,6 +1255,7 @@ def main() -> None: #include "api_pb2.h" #include "api_pb2_size.h" #include "esphome/core/log.h" + #include "esphome/core/helpers.h" #include From 03566c34ed014d6babad37dfc3cbe6fb4772e214 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Jul 2025 20:43:40 -0500 Subject: [PATCH 02/17] Reduce Component memory usage by 40% (8 bytes per component) (#9278) --- esphome/core/application.cpp | 4 ++ esphome/core/component.cpp | 94 +++++++++++++++++++++++++++++++++--- esphome/core/component.h | 5 +- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1599c648e7..d6fab018cc 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -84,6 +84,10 @@ void Application::setup() { } ESP_LOGI(TAG, "setup() finished successfully!"); + + // Clear setup priority overrides to free memory + clear_setup_priority_overrides(); + this->schedule_dump_config(); this->calculate_looping_components_(); } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 6661223e35..aba5dc729c 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -12,6 +14,30 @@ namespace esphome { static const char *const TAG = "component"; +// Global vectors for component data that doesn't belong in every instance. +// Using vector instead of unordered_map for both because: +// - Much lower memory overhead (8 bytes per entry vs 20+ for unordered_map) +// - Linear search is fine for small n (typically < 5 entries) +// - These are rarely accessed (setup only or error cases only) + +// Component error messages - only stores messages for failed components +// Lazy allocated since most configs have zero failures +// Note: We don't clear this vector because: +// 1. Components are never destroyed in ESPHome +// 2. Failed components remain failed (no recovery mechanism) +// 3. Memory usage is minimal (only failures with custom messages are stored) +static std::unique_ptr>> &get_component_error_messages() { + static std::unique_ptr>> instance; + return instance; +} + +// Setup priority overrides - freed after setup completes +// Typically < 5 entries, lazy allocated +static std::unique_ptr>> &get_setup_priority_overrides() { + static std::unique_ptr>> instance; + return instance; +} + namespace setup_priority { const float BUS = 1000.0f; @@ -102,8 +128,17 @@ void Component::call_setup() { this->setup(); } void Component::call_dump_config() { this->dump_config(); if (this->is_failed()) { - ESP_LOGE(TAG, " Component %s is marked FAILED: %s", this->get_component_source(), - this->error_message_ ? this->error_message_ : "unspecified"); + // Look up error message from global vector + const char *error_msg = "unspecified"; + if (get_component_error_messages()) { + for (const auto &pair : *get_component_error_messages()) { + if (pair.first == this) { + error_msg = pair.second; + break; + } + } + } + ESP_LOGE(TAG, " Component %s is marked FAILED: %s", this->get_component_source(), error_msg); } } @@ -245,8 +280,21 @@ void Component::status_set_error(const char *message) { this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; ESP_LOGE(TAG, "Component %s set Error flag: %s", this->get_component_source(), message); - if (strcmp(message, "unspecified") != 0) - this->error_message_ = message; + if (strcmp(message, "unspecified") != 0) { + // Lazy allocate the error messages vector if needed + if (!get_component_error_messages()) { + get_component_error_messages() = std::make_unique>>(); + } + // Check if this component already has an error message + for (auto &pair : *get_component_error_messages()) { + if (pair.first == this) { + pair.second = message; + return; + } + } + // Add new error message + get_component_error_messages()->emplace_back(this, message); + } } void Component::status_clear_warning() { if ((this->component_state_ & STATUS_LED_WARNING) == 0) @@ -270,11 +318,36 @@ void Component::status_momentary_error(const std::string &name, uint32_t length) } void Component::dump_config() {} float Component::get_actual_setup_priority() const { - if (std::isnan(this->setup_priority_override_)) - return this->get_setup_priority(); - return this->setup_priority_override_; + // Check if there's an override in the global vector + if (get_setup_priority_overrides()) { + // Linear search is fine for small n (typically < 5 overrides) + for (const auto &pair : *get_setup_priority_overrides()) { + if (pair.first == this) { + return pair.second; + } + } + } + return this->get_setup_priority(); +} +void Component::set_setup_priority(float priority) { + // Lazy allocate the vector if needed + if (!get_setup_priority_overrides()) { + get_setup_priority_overrides() = std::make_unique>>(); + // Reserve some space to avoid reallocations (most configs have < 10 overrides) + get_setup_priority_overrides()->reserve(10); + } + + // Check if this component already has an override + for (auto &pair : *get_setup_priority_overrides()) { + if (pair.first == this) { + pair.second = priority; + return; + } + } + + // Add new override + get_setup_priority_overrides()->emplace_back(this, priority); } -void Component::set_setup_priority(float priority) { this->setup_priority_override_ = priority; } bool Component::has_overridden_loop() const { #if defined(USE_HOST) || defined(CLANG_TIDY) @@ -336,4 +409,9 @@ uint32_t WarnIfComponentBlockingGuard::finish() { WarnIfComponentBlockingGuard::~WarnIfComponentBlockingGuard() {} +void clear_setup_priority_overrides() { + // Free the setup priority map completely + get_setup_priority_overrides().reset(); +} + } // namespace esphome diff --git a/esphome/core/component.h b/esphome/core/component.h index 5b37deeb68..ab30466e2d 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -387,9 +387,7 @@ class Component { bool cancel_defer(const std::string &name); // NOLINT // Ordered for optimal packing on 32-bit systems - float setup_priority_override_{NAN}; const char *component_source_{nullptr}; - const char *error_message_{nullptr}; uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s) /// State of this component - each bit has a purpose: /// Bits 0-1: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED) @@ -459,4 +457,7 @@ class WarnIfComponentBlockingGuard { Component *component_; }; +// Function to clear setup priority overrides after all components are set up +void clear_setup_priority_overrides(); + } // namespace esphome From 84ab758b227c014d001a1736b7ed7745f892fc96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Jul 2025 20:50:45 -0500 Subject: [PATCH 03/17] Replace custom OTA implementation in web_server_base (#9274) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/captive_portal/__init__.py | 2 +- .../captive_portal/captive_portal.cpp | 3 - esphome/components/ota/ota_backend.h | 28 +- .../ota/ota_backend_arduino_esp32.cpp | 14 +- .../ota/ota_backend_arduino_esp32.h | 3 + .../ota/ota_backend_arduino_esp8266.cpp | 22 +- .../ota/ota_backend_arduino_esp8266.h | 3 + .../ota/ota_backend_arduino_libretiny.cpp | 14 +- .../ota/ota_backend_arduino_libretiny.h | 3 + .../ota/ota_backend_arduino_rp2040.cpp | 11 +- .../ota/ota_backend_arduino_rp2040.h | 3 + .../components/ota/ota_backend_esp_idf.cpp | 15 +- esphome/components/ota/ota_backend_esp_idf.h | 1 + esphome/components/web_server/__init__.py | 35 ++- esphome/components/web_server/ota/__init__.py | 32 +++ .../web_server/ota/ota_web_server.cpp | 210 ++++++++++++++ .../web_server/ota/ota_web_server.h | 26 ++ esphome/components/web_server/web_server.cpp | 11 +- esphome/components/web_server/web_server.h | 6 - .../components/web_server/web_server_v1.cpp | 9 +- .../components/web_server_base/__init__.py | 1 + .../web_server_base/web_server_base.cpp | 264 +----------------- .../web_server_base/web_server_base.h | 42 +-- esphome/components/web_server_idf/__init__.py | 8 +- esphome/config.py | 109 ++++++-- .../ota/test_web_server_ota.py | 102 +++++++ .../ota/test_web_server_ota.yaml | 15 + .../ota/test_web_server_ota_arduino.yaml | 18 ++ .../ota/test_web_server_ota_callbacks.yaml | 31 ++ .../ota/test_web_server_ota_esp8266.yaml | 15 + .../ota/test_web_server_ota_idf.yaml | 17 ++ .../ota/test_web_server_ota_multi.yaml | 21 ++ .../web_server/test_ota_migration.py | 38 +++ .../web_server/test_no_ota.esp32-idf.yaml | 9 +- .../web_server/test_ota.esp32-idf.yaml | 8 +- .../test_ota_disabled.esp32-idf.yaml | 11 +- 37 files changed, 776 insertions(+), 385 deletions(-) create mode 100644 esphome/components/web_server/ota/__init__.py create mode 100644 esphome/components/web_server/ota/ota_web_server.cpp create mode 100644 esphome/components/web_server/ota/ota_web_server.h create mode 100644 tests/component_tests/ota/test_web_server_ota.py create mode 100644 tests/component_tests/ota/test_web_server_ota.yaml create mode 100644 tests/component_tests/ota/test_web_server_ota_arduino.yaml create mode 100644 tests/component_tests/ota/test_web_server_ota_callbacks.yaml create mode 100644 tests/component_tests/ota/test_web_server_ota_esp8266.yaml create mode 100644 tests/component_tests/ota/test_web_server_ota_idf.yaml create mode 100644 tests/component_tests/ota/test_web_server_ota_multi.yaml create mode 100644 tests/component_tests/web_server/test_ota_migration.py diff --git a/CODEOWNERS b/CODEOWNERS index 68c8684024..16f38da725 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -498,6 +498,7 @@ esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher esphome/components/waveshare_epaper/* @clydebarrow +esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @OttoWinter esphome/components/web_server_idf/* @dentra esphome/components/weikai/* @DrCoolZic diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cba3b4921a..7e8afd8fab 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -12,7 +12,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority -AUTO_LOAD = ["web_server_base"] +AUTO_LOAD = ["web_server_base", "ota.web_server"] DEPENDENCIES = ["wifi"] CODEOWNERS = ["@OttoWinter"] diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index ba392bb0f2..25179fdacc 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -47,9 +47,6 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); -#ifdef USE_WEBSERVER_OTA - this->base_->add_ota_handler(); -#endif } #ifdef USE_ARDUINO diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index bc8ab46643..372f24df5e 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -67,7 +67,28 @@ class OTAComponent : public Component { } protected: - CallbackManager state_callback_{}; + /** Extended callback manager with deferred call support. + * + * This adds a call_deferred() method for thread-safe execution from other tasks. + */ + class StateCallbackManager : public CallbackManager { + public: + StateCallbackManager(OTAComponent *component) : component_(component) {} + + /** Call callbacks with deferral to main loop (for thread safety). + * + * This should be used by OTA implementations that run in separate tasks + * (like web_server OTA) to ensure callbacks execute in the main loop. + */ + void call_deferred(ota::OTAState state, float progress, uint8_t error) { + component_->defer([this, state, progress, error]() { this->call(state, progress, error); }); + } + + private: + OTAComponent *component_; + }; + + StateCallbackManager state_callback_{this}; #endif }; @@ -89,6 +110,11 @@ class OTAGlobalCallback { OTAGlobalCallback *get_global_ota_callback(); void register_ota_platform(OTAComponent *ota_caller); + +// OTA implementations should use: +// - state_callback_.call() when already in main loop (e.g., esphome OTA) +// - state_callback_.call_deferred() when in separate task (e.g., web_server OTA) +// This ensures proper callback execution in all contexts. #endif std::unique_ptr make_ota_backend(); diff --git a/esphome/components/ota/ota_backend_arduino_esp32.cpp b/esphome/components/ota/ota_backend_arduino_esp32.cpp index 15dfc98a6c..5c6230f2ce 100644 --- a/esphome/components/ota/ota_backend_arduino_esp32.cpp +++ b/esphome/components/ota/ota_backend_arduino_esp32.cpp @@ -15,6 +15,11 @@ static const char *const TAG = "ota.arduino_esp32"; std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) { + // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA + // where the exact firmware size is unknown due to multipart encoding + if (image_size == 0) { + image_size = UPDATE_SIZE_UNKNOWN; + } bool ret = Update.begin(image_size, U_FLASH); if (ret) { return OTA_RESPONSE_OK; @@ -29,7 +34,10 @@ OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoESP32OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); } +void ArduinoESP32OTABackend::set_update_md5(const char *md5) { + Update.setMD5(md5); + this->md5_set_ = true; +} OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); @@ -44,7 +52,9 @@ OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) { } OTAResponseTypes ArduinoESP32OTABackend::end() { - if (Update.end()) { + // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 + // This matches the behavior of the old web_server OTA implementation + if (Update.end(!this->md5_set_)) { return OTA_RESPONSE_OK; } diff --git a/esphome/components/ota/ota_backend_arduino_esp32.h b/esphome/components/ota/ota_backend_arduino_esp32.h index ac7fe9f14f..6615cf3dc0 100644 --- a/esphome/components/ota/ota_backend_arduino_esp32.h +++ b/esphome/components/ota/ota_backend_arduino_esp32.h @@ -16,6 +16,9 @@ class ArduinoESP32OTABackend : public OTABackend { OTAResponseTypes end() override; void abort() override; bool supports_compression() override { return false; } + + private: + bool md5_set_{false}; }; } // namespace ota diff --git a/esphome/components/ota/ota_backend_arduino_esp8266.cpp b/esphome/components/ota/ota_backend_arduino_esp8266.cpp index 42edbf5d2b..375c4e7200 100644 --- a/esphome/components/ota/ota_backend_arduino_esp8266.cpp +++ b/esphome/components/ota/ota_backend_arduino_esp8266.cpp @@ -17,6 +17,11 @@ static const char *const TAG = "ota.arduino_esp8266"; std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoESP8266OTABackend::begin(size_t image_size) { + // Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space + if (image_size == 0) { + // NOLINTNEXTLINE(readability-static-accessed-through-instance) + image_size = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; + } bool ret = Update.begin(image_size, U_FLASH); if (ret) { esp8266::preferences_prevent_write(true); @@ -38,7 +43,10 @@ OTAResponseTypes ArduinoESP8266OTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoESP8266OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); } +void ArduinoESP8266OTABackend::set_update_md5(const char *md5) { + Update.setMD5(md5); + this->md5_set_ = true; +} OTAResponseTypes ArduinoESP8266OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); @@ -53,13 +61,19 @@ OTAResponseTypes ArduinoESP8266OTABackend::write(uint8_t *data, size_t len) { } OTAResponseTypes ArduinoESP8266OTABackend::end() { - if (Update.end()) { + // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 + // This matches the behavior of the old web_server OTA implementation + bool success = Update.end(!this->md5_set_); + + // On ESP8266, Update.end() might return false even with error code 0 + // Check the actual error code to determine success + uint8_t error = Update.getError(); + + if (success || error == UPDATE_ERROR_OK) { return OTA_RESPONSE_OK; } - uint8_t error = Update.getError(); ESP_LOGE(TAG, "End error: %d", error); - return OTA_RESPONSE_ERROR_UPDATE_END; } diff --git a/esphome/components/ota/ota_backend_arduino_esp8266.h b/esphome/components/ota/ota_backend_arduino_esp8266.h index 7f44d7c965..e1b9015cc7 100644 --- a/esphome/components/ota/ota_backend_arduino_esp8266.h +++ b/esphome/components/ota/ota_backend_arduino_esp8266.h @@ -21,6 +21,9 @@ class ArduinoESP8266OTABackend : public OTABackend { #else bool supports_compression() override { return false; } #endif + + private: + bool md5_set_{false}; }; } // namespace ota diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 6b2cf80684..b4ecad1227 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -15,6 +15,11 @@ static const char *const TAG = "ota.arduino_libretiny"; std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) { + // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA + // where the exact firmware size is unknown due to multipart encoding + if (image_size == 0) { + image_size = UPDATE_SIZE_UNKNOWN; + } bool ret = Update.begin(image_size, U_FLASH); if (ret) { return OTA_RESPONSE_OK; @@ -29,7 +34,10 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoLibreTinyOTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); } +void ArduinoLibreTinyOTABackend::set_update_md5(const char *md5) { + Update.setMD5(md5); + this->md5_set_ = true; +} OTAResponseTypes ArduinoLibreTinyOTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); @@ -44,7 +52,9 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::write(uint8_t *data, size_t len) { } OTAResponseTypes ArduinoLibreTinyOTABackend::end() { - if (Update.end()) { + // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 + // This matches the behavior of the old web_server OTA implementation + if (Update.end(!this->md5_set_)) { return OTA_RESPONSE_OK; } diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 11deb6e2f2..6d9b7a96d5 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -15,6 +15,9 @@ class ArduinoLibreTinyOTABackend : public OTABackend { OTAResponseTypes end() override; void abort() override; bool supports_compression() override { return false; } + + private: + bool md5_set_{false}; }; } // namespace ota diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index ffeab2e93f..ee1ba48d50 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -17,6 +17,8 @@ static const char *const TAG = "ota.arduino_rp2040"; std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) { + // OTA size of 0 is not currently handled, but + // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); if (ret) { rp2040::preferences_prevent_write(true); @@ -38,7 +40,10 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); } +void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { + Update.setMD5(md5); + this->md5_set_ = true; +} OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); @@ -53,7 +58,9 @@ OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { } OTAResponseTypes ArduinoRP2040OTABackend::end() { - if (Update.end()) { + // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 + // This matches the behavior of the old web_server OTA implementation + if (Update.end(!this->md5_set_)) { return OTA_RESPONSE_OK; } diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index b189964ab3..b9e10d506c 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -17,6 +17,9 @@ class ArduinoRP2040OTABackend : public OTABackend { OTAResponseTypes end() override; void abort() override; bool supports_compression() override { return false; } + + private: + bool md5_set_{false}; }; } // namespace ota diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index cad44a5795..97aae09bd9 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -56,7 +56,10 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size) { return OTA_RESPONSE_OK; } -void IDFOTABackend::set_update_md5(const char *expected_md5) { memcpy(this->expected_bin_md5_, expected_md5, 32); } +void IDFOTABackend::set_update_md5(const char *expected_md5) { + memcpy(this->expected_bin_md5_, expected_md5, 32); + this->md5_set_ = true; +} OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { esp_err_t err = esp_ota_write(this->update_handle_, data, len); @@ -73,10 +76,12 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { } OTAResponseTypes IDFOTABackend::end() { - this->md5_.calculate(); - if (!this->md5_.equals_hex(this->expected_bin_md5_)) { - this->abort(); - return OTA_RESPONSE_ERROR_MD5_MISMATCH; + if (this->md5_set_) { + this->md5_.calculate(); + if (!this->md5_.equals_hex(this->expected_bin_md5_)) { + this->abort(); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } } esp_err_t err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index ed66d9b970..6e93982131 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -24,6 +24,7 @@ class IDFOTABackend : public OTABackend { const esp_partition_t *partition_; md5::MD5Digest md5_{}; char expected_bin_md5_[32]; + bool md5_set_{false}; }; } // namespace ota diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index ca145c732b..6890f60014 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -33,6 +33,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["json", "web_server_base"] @@ -47,7 +48,7 @@ WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) sorting_groups = {} -def default_url(config): +def default_url(config: ConfigType) -> ConfigType: config = config.copy() if config[CONF_VERSION] == 1: if CONF_CSS_URL not in config: @@ -67,13 +68,27 @@ def default_url(config): return config -def validate_local(config): +def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") return config -def validate_sorting_groups(config): +def validate_ota_removed(config: ConfigType) -> ConfigType: + # Only raise error if OTA is explicitly enabled (True) + # If it's False or not specified, we can safely ignore it + if config.get(CONF_OTA): + raise cv.Invalid( + f"The '{CONF_OTA}' option has been removed from 'web_server'. " + f"Please use the new OTA platform structure instead:\n\n" + f"ota:\n" + f" - platform: web_server\n\n" + f"See https://esphome.io/components/ota for more information." + ) + return config + + +def validate_sorting_groups(config: ConfigType) -> ConfigType: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( f"'{CONF_SORTING_GROUPS}' is only supported in 'web_server' version 3" @@ -84,7 +99,7 @@ def validate_sorting_groups(config): def _validate_no_sorting_component( sorting_component: str, webserver_version: int, - config: dict, + config: ConfigType, path: list[str] | None = None, ) -> None: if path is None: @@ -107,7 +122,7 @@ def _validate_no_sorting_component( ) -def _final_validate_sorting(config): +def _final_validate_sorting(config: ConfigType) -> ConfigType: if (webserver_version := config.get(CONF_VERSION)) != 3: _validate_no_sorting_component( CONF_SORTING_WEIGHT, webserver_version, fv.full_config.get() @@ -170,7 +185,7 @@ CONFIG_SCHEMA = cv.All( web_server_base.WebServerBase ), cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean, - cv.Optional(CONF_OTA, default=True): cv.boolean, + cv.Optional(CONF_OTA, default=False): cv.boolean, cv.Optional(CONF_LOG, default=True): cv.boolean, cv.Optional(CONF_LOCAL): cv.boolean, cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group), @@ -188,6 +203,7 @@ CONFIG_SCHEMA = cv.All( default_url, validate_local, validate_sorting_groups, + validate_ota_removed, ) @@ -271,11 +287,8 @@ async def to_code(config): else: cg.add(var.set_css_url(config[CONF_CSS_URL])) cg.add(var.set_js_url(config[CONF_JS_URL])) - cg.add(var.set_allow_ota(config[CONF_OTA])) - if config[CONF_OTA]: - # Define USE_WEBSERVER_OTA based only on web_server OTA config - # This allows web server OTA to work without loading the OTA component - cg.add_define("USE_WEBSERVER_OTA") + # OTA is now handled by the web_server OTA platform + # The CONF_OTA option is kept only for backwards compatibility validation cg.add(var.set_expose_log(config[CONF_LOG])) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py new file mode 100644 index 0000000000..3af14fd453 --- /dev/null +++ b/esphome/components/web_server/ota/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components.esp32 import add_idf_component +from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, coroutine_with_priority + +CODEOWNERS = ["@esphome/core"] +DEPENDENCIES = ["network", "web_server_base"] + +web_server_ns = cg.esphome_ns.namespace("web_server") +WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(WebServerOTAComponent), + } + ) + .extend(BASE_OTA_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +@coroutine_with_priority(52.0) +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await ota_to_code(var, config) + await cg.register_component(var, config) + cg.add_define("USE_WEBSERVER_OTA") + if CORE.using_esp_idf: + add_idf_component(name="zorxx/multipart-parser", ref="1.0.1") diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp new file mode 100644 index 0000000000..4f8f6fda17 --- /dev/null +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -0,0 +1,210 @@ +#include "ota_web_server.h" +#ifdef USE_WEBSERVER_OTA + +#include "esphome/components/ota/ota_backend.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#ifdef USE_ARDUINO +#ifdef USE_ESP8266 +#include +#elif defined(USE_ESP32) || defined(USE_LIBRETINY) +#include +#endif +#endif // USE_ARDUINO + +namespace esphome { +namespace web_server { + +static const char *const TAG = "web_server.ota"; + +class OTARequestHandler : public AsyncWebHandler { + public: + OTARequestHandler(WebServerOTAComponent *parent) : parent_(parent) {} + void handleRequest(AsyncWebServerRequest *request) override; + void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, + bool final) override; + bool canHandle(AsyncWebServerRequest *request) const override { + return request->url() == "/update" && request->method() == HTTP_POST; + } + + // NOLINTNEXTLINE(readability-identifier-naming) + bool isRequestHandlerTrivial() const override { return false; } + + protected: + void report_ota_progress_(AsyncWebServerRequest *request); + void schedule_ota_reboot_(); + void ota_init_(const char *filename); + + uint32_t last_ota_progress_{0}; + uint32_t ota_read_length_{0}; + WebServerOTAComponent *parent_; + bool ota_success_{false}; + + private: + std::unique_ptr ota_backend_{nullptr}; +}; + +void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { + const uint32_t now = millis(); + if (now - this->last_ota_progress_ > 1000) { + float percentage = 0.0f; + if (request->contentLength() != 0) { + // Note: Using contentLength() for progress calculation is technically wrong as it includes + // multipart headers/boundaries, but it's only off by a small amount and we don't have + // access to the actual firmware size until the upload is complete. This is intentional + // as it still gives the user a reasonable progress indication. + percentage = (this->ota_read_length_ * 100.0f) / request->contentLength(); + ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage); + } else { + ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_); + } +#ifdef USE_OTA_STATE_CALLBACK + // Report progress - use call_deferred since we're in web server task + this->parent_->state_callback_.call_deferred(ota::OTA_IN_PROGRESS, percentage, 0); +#endif + this->last_ota_progress_ = now; + } +} + +void OTARequestHandler::schedule_ota_reboot_() { + ESP_LOGI(TAG, "OTA update successful!"); + this->parent_->set_timeout(100, []() { + ESP_LOGI(TAG, "Performing OTA reboot now"); + App.safe_reboot(); + }); +} + +void OTARequestHandler::ota_init_(const char *filename) { + ESP_LOGI(TAG, "OTA Update Start: %s", filename); + this->ota_read_length_ = 0; + this->ota_success_ = false; +} + +void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, + uint8_t *data, size_t len, bool final) { + ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; + + if (index == 0 && !this->ota_backend_) { + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_CALLBACK + // Notify OTA started - use call_deferred since we're in web server task + this->parent_->state_callback_.call_deferred(ota::OTA_STARTED, 0.0f, 0); +#endif + + // Platform-specific pre-initialization +#ifdef USE_ARDUINO +#ifdef USE_ESP8266 + Update.runAsync(true); +#endif +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + if (Update.isRunning()) { + Update.abort(); + } +#endif +#endif // USE_ARDUINO + + this->ota_backend_ = ota::make_ota_backend(); + if (!this->ota_backend_) { + ESP_LOGE(TAG, "Failed to create OTA backend"); +#ifdef USE_OTA_STATE_CALLBACK + this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, + static_cast(ota::OTA_RESPONSE_ERROR_UNKNOWN)); +#endif + return; + } + + // Web server OTA uses multipart uploads where the actual firmware size + // is unknown (contentLength includes multipart overhead) + // Pass 0 to indicate unknown size + 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(); +#ifdef USE_OTA_STATE_CALLBACK + this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#endif + return; + } + } + + if (!this->ota_backend_) { + return; + } + + // Process data + if (len > 0) { + error_code = this->ota_backend_->write(data, len); + if (error_code != ota::OTA_RESPONSE_OK) { + ESP_LOGE(TAG, "OTA write failed: %d", error_code); + this->ota_backend_->abort(); + this->ota_backend_.reset(); +#ifdef USE_OTA_STATE_CALLBACK + this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#endif + return; + } + this->ota_read_length_ += len; + this->report_ota_progress_(request); + } + + // Finalize + if (final) { + ESP_LOGD(TAG, "OTA final chunk: index=%u, len=%u, total_read=%u, contentLength=%u", index, len, + this->ota_read_length_, request->contentLength()); + + // For Arduino framework, the Update library tracks expected size from firmware header + // If we haven't received enough data, calling end() will fail + // This can happen if the upload is interrupted or the client disconnects + error_code = this->ota_backend_->end(); + if (error_code == ota::OTA_RESPONSE_OK) { + this->ota_success_ = true; +#ifdef USE_OTA_STATE_CALLBACK + // Report completion before reboot - use call_deferred since we're in web server task + this->parent_->state_callback_.call_deferred(ota::OTA_COMPLETED, 100.0f, 0); +#endif + this->schedule_ota_reboot_(); + } else { + ESP_LOGE(TAG, "OTA end failed: %d", error_code); +#ifdef USE_OTA_STATE_CALLBACK + this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#endif + } + this->ota_backend_.reset(); + } +} + +void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { + AsyncWebServerResponse *response; + // Use the ota_success_ flag to determine the actual result + const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + response = request->beginResponse(200, "text/plain", msg); + response->addHeader("Connection", "close"); + request->send(response); +} + +void WebServerOTAComponent::setup() { + // Get the global web server base instance and register our handler + auto *base = web_server_base::global_web_server_base; + if (base == nullptr) { + ESP_LOGE(TAG, "WebServerBase not found"); + this->mark_failed(); + return; + } + + // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + base->add_handler(new OTARequestHandler(this)); // NOLINT +#ifdef USE_OTA_STATE_CALLBACK + // Register with global OTA callback system + ota::register_ota_platform(this); +#endif +} + +void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); } + +} // namespace web_server +} // namespace esphome + +#endif // USE_WEBSERVER_OTA diff --git a/esphome/components/web_server/ota/ota_web_server.h b/esphome/components/web_server/ota/ota_web_server.h new file mode 100644 index 0000000000..a7170c0e34 --- /dev/null +++ b/esphome/components/web_server/ota/ota_web_server.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_WEBSERVER_OTA + +#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace web_server { + +class WebServerOTAComponent : public ota::OTAComponent { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + protected: + friend class OTARequestHandler; +}; + +} // namespace web_server +} // namespace esphome + +#endif // USE_WEBSERVER_OTA diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index e0027d0b27..d5ded2a02c 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -273,7 +273,11 @@ std::string WebServer::get_config_json() { return json::build_json([this](JsonObject root) { root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); root["comment"] = App.get_comment(); - root["ota"] = this->allow_ota_; +#ifdef USE_WEBSERVER_OTA + root["ota"] = true; // web_server OTA platform is configured +#else + root["ota"] = false; +#endif root["log"] = this->expose_log_; root["lang"] = "en"; }); @@ -299,10 +303,7 @@ void WebServer::setup() { #endif this->base_->add_handler(this); -#ifdef USE_WEBSERVER_OTA - if (this->allow_ota_) - this->base_->add_ota_handler(); -#endif + // OTA is now handled by the web_server OTA platform // doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly // getting a lot of events diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 991bca6fa7..5f175b6bdd 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -212,11 +212,6 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { * @param include_internal Whether internal components should be displayed. */ void set_include_internal(bool include_internal) { include_internal_ = include_internal; } - /** Set whether or not the webserver should expose the OTA form and handler. - * - * @param allow_ota. - */ - void set_allow_ota(bool allow_ota) { this->allow_ota_ = allow_ota; } /** Set whether or not the webserver should expose the Log. * * @param expose_log. @@ -525,7 +520,6 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #ifdef USE_WEBSERVER_JS_INCLUDE const char *js_include_{nullptr}; #endif - bool allow_ota_{true}; bool expose_log_{true}; #ifdef USE_ESP32 std::deque> to_schedule_; diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index c9b38a2dc4..5db0f1cae9 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -192,11 +192,10 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { stream->print(F("

See ESPHome Web API for " "REST API documentation.

")); - if (this->allow_ota_) { - stream->print( - F("

OTA Update

")); - } +#ifdef USE_WEBSERVER_OTA + stream->print(F("

OTA Update

")); +#endif stream->print(F("

Debug Log

"));
 #ifdef USE_WEBSERVER_JS_INCLUDE
   if (this->js_include_ != nullptr) {
diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py
index c17bab2128..754bf7d433 100644
--- a/esphome/components/web_server_base/__init__.py
+++ b/esphome/components/web_server_base/__init__.py
@@ -30,6 +30,7 @@ CONFIG_SCHEMA = cv.Schema(
 async def to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     await cg.register_component(var, config)
+    cg.add(cg.RawExpression(f"{web_server_base_ns}::global_web_server_base = {var}"))
 
     if CORE.using_arduino:
         if CORE.is_esp32:
diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp
index 9ad88e09f4..e1c2bc0b25 100644
--- a/esphome/components/web_server_base/web_server_base.cpp
+++ b/esphome/components/web_server_base/web_server_base.cpp
@@ -4,123 +4,12 @@
 #include "esphome/core/helpers.h"
 #include "esphome/core/log.h"
 
-#ifdef USE_ARDUINO
-#include 
-#if defined(USE_ESP32) || defined(USE_LIBRETINY)
-#include 
-#endif
-#ifdef USE_ESP8266
-#include 
-#endif
-#endif
-
-#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA)
-#include 
-#include 
-#endif
-
 namespace esphome {
 namespace web_server_base {
 
 static const char *const TAG = "web_server_base";
 
-#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA)
-// Minimal OTA backend implementation for web server
-// This allows OTA updates via web server without requiring the OTA component
-// TODO: In the future, this should be refactored into a common ota_base component
-// that both web_server and ota components can depend on, avoiding code duplication
-// while keeping the components independent. This would allow both ESP-IDF and Arduino
-// implementations to share the base OTA functionality without requiring the full OTA component.
-// The IDFWebServerOTABackend class is intentionally designed with the same interface
-// as OTABackend to make it easy to swap to using OTABackend when the ota component
-// is split into ota and ota_base in the future.
-class IDFWebServerOTABackend {
- public:
-  bool begin() {
-    this->partition_ = esp_ota_get_next_update_partition(nullptr);
-    if (this->partition_ == nullptr) {
-      ESP_LOGE(TAG, "No OTA partition available");
-      return false;
-    }
-
-#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15
-    // The following function takes longer than the default timeout of WDT due to flash erase
-#if ESP_IDF_VERSION_MAJOR >= 5
-    esp_task_wdt_config_t wdtc;
-    wdtc.idle_core_mask = 0;
-#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0
-    wdtc.idle_core_mask |= (1 << 0);
-#endif
-#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1
-    wdtc.idle_core_mask |= (1 << 1);
-#endif
-    wdtc.timeout_ms = 15000;
-    wdtc.trigger_panic = false;
-    esp_task_wdt_reconfigure(&wdtc);
-#else
-    esp_task_wdt_init(15, false);
-#endif
-#endif
-
-    esp_err_t err = esp_ota_begin(this->partition_, 0, &this->update_handle_);
-
-#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15
-    // Set the WDT back to the configured timeout
-#if ESP_IDF_VERSION_MAJOR >= 5
-    wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000;
-    esp_task_wdt_reconfigure(&wdtc);
-#else
-    esp_task_wdt_init(CONFIG_ESP_TASK_WDT_TIMEOUT_S, false);
-#endif
-#endif
-
-    if (err != ESP_OK) {
-      esp_ota_abort(this->update_handle_);
-      this->update_handle_ = 0;
-      ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
-      return false;
-    }
-    return true;
-  }
-
-  bool write(uint8_t *data, size_t len) {
-    esp_err_t err = esp_ota_write(this->update_handle_, data, len);
-    if (err != ESP_OK) {
-      ESP_LOGE(TAG, "esp_ota_write failed: %s", esp_err_to_name(err));
-      return false;
-    }
-    return true;
-  }
-
-  bool end() {
-    esp_err_t err = esp_ota_end(this->update_handle_);
-    this->update_handle_ = 0;
-    if (err != ESP_OK) {
-      ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
-      return false;
-    }
-
-    err = esp_ota_set_boot_partition(this->partition_);
-    if (err != ESP_OK) {
-      ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
-      return false;
-    }
-
-    return true;
-  }
-
-  void abort() {
-    if (this->update_handle_ != 0) {
-      esp_ota_abort(this->update_handle_);
-      this->update_handle_ = 0;
-    }
-  }
-
- private:
-  esp_ota_handle_t update_handle_{0};
-  const esp_partition_t *partition_{nullptr};
-};
-#endif
+WebServerBase *global_web_server_base = nullptr;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
 
 void WebServerBase::add_handler(AsyncWebHandler *handler) {
   // remove all handlers
@@ -134,157 +23,6 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) {
   }
 }
 
-#ifdef USE_WEBSERVER_OTA
-void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
-  const uint32_t now = millis();
-  if (now - this->last_ota_progress_ > 1000) {
-    if (request->contentLength() != 0) {
-      float percentage = (this->ota_read_length_ * 100.0f) / request->contentLength();
-      ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage);
-    } else {
-      ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_);
-    }
-    this->last_ota_progress_ = now;
-  }
-}
-
-void OTARequestHandler::schedule_ota_reboot_() {
-  ESP_LOGI(TAG, "OTA update successful!");
-  this->parent_->set_timeout(100, []() {
-    ESP_LOGI(TAG, "Performing OTA reboot now");
-    App.safe_reboot();
-  });
-}
-
-void OTARequestHandler::ota_init_(const char *filename) {
-  ESP_LOGI(TAG, "OTA Update Start: %s", filename);
-  this->ota_read_length_ = 0;
-}
-
-void report_ota_error() {
-#ifdef USE_ARDUINO
-  StreamString ss;
-  Update.printError(ss);
-  ESP_LOGW(TAG, "OTA Update failed! Error: %s", ss.c_str());
-#endif
-}
-
-void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index,
-                                     uint8_t *data, size_t len, bool final) {
-#ifdef USE_ARDUINO
-  bool success;
-  if (index == 0) {
-    this->ota_init_(filename.c_str());
-#ifdef USE_ESP8266
-    Update.runAsync(true);
-    // NOLINTNEXTLINE(readability-static-accessed-through-instance)
-    success = Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000);
-#endif
-#if defined(USE_ESP32_FRAMEWORK_ARDUINO) || defined(USE_LIBRETINY)
-    if (Update.isRunning()) {
-      Update.abort();
-    }
-    success = Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH);
-#endif
-    if (!success) {
-      report_ota_error();
-      return;
-    }
-  } else if (Update.hasError()) {
-    // don't spam logs with errors if something failed at start
-    return;
-  }
-
-  success = Update.write(data, len) == len;
-  if (!success) {
-    report_ota_error();
-    return;
-  }
-  this->ota_read_length_ += len;
-  this->report_ota_progress_(request);
-
-  if (final) {
-    if (Update.end(true)) {
-      this->schedule_ota_reboot_();
-    } else {
-      report_ota_error();
-    }
-  }
-#endif  // USE_ARDUINO
-
-#ifdef USE_ESP_IDF
-  // ESP-IDF implementation
-  if (index == 0 && !this->ota_backend_) {
-    // Initialize OTA on first call
-    this->ota_init_(filename.c_str());
-    this->ota_success_ = false;
-
-    auto *backend = new IDFWebServerOTABackend();
-    if (!backend->begin()) {
-      ESP_LOGE(TAG, "OTA begin failed");
-      delete backend;
-      return;
-    }
-    this->ota_backend_ = backend;
-  }
-
-  auto *backend = static_cast(this->ota_backend_);
-  if (!backend) {
-    return;
-  }
-
-  // Process data
-  if (len > 0) {
-    if (!backend->write(data, len)) {
-      ESP_LOGE(TAG, "OTA write failed");
-      backend->abort();
-      delete backend;
-      this->ota_backend_ = nullptr;
-      return;
-    }
-    this->ota_read_length_ += len;
-    this->report_ota_progress_(request);
-  }
-
-  // Finalize
-  if (final) {
-    this->ota_success_ = backend->end();
-    if (this->ota_success_) {
-      this->schedule_ota_reboot_();
-    } else {
-      ESP_LOGE(TAG, "OTA end failed");
-    }
-    delete backend;
-    this->ota_backend_ = nullptr;
-  }
-#endif  // USE_ESP_IDF
-}
-
-void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) {
-  AsyncWebServerResponse *response;
-#ifdef USE_ARDUINO
-  if (!Update.hasError()) {
-    response = request->beginResponse(200, "text/plain", "Update Successful!");
-  } else {
-    StreamString ss;
-    ss.print("Update Failed: ");
-    Update.printError(ss);
-    response = request->beginResponse(200, "text/plain", ss);
-  }
-#endif  // USE_ARDUINO
-#ifdef USE_ESP_IDF
-  // Send response based on the OTA result
-  response = request->beginResponse(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!");
-#endif  // USE_ESP_IDF
-  response->addHeader("Connection", "close");
-  request->send(response);
-}
-
-void WebServerBase::add_ota_handler() {
-  this->add_handler(new OTARequestHandler(this));  // NOLINT
-}
-#endif
-
 float WebServerBase::get_setup_priority() const {
   // Before WiFi (captive portal)
   return setup_priority::WIFI + 2.0f;
diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h
index 09a41956c9..a475238a37 100644
--- a/esphome/components/web_server_base/web_server_base.h
+++ b/esphome/components/web_server_base/web_server_base.h
@@ -17,6 +17,9 @@
 namespace esphome {
 namespace web_server_base {
 
+class WebServerBase;
+extern WebServerBase *global_web_server_base;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+
 namespace internal {
 
 class MiddlewareHandler : public AsyncWebHandler {
@@ -110,18 +113,10 @@ class WebServerBase : public Component {
 
   void add_handler(AsyncWebHandler *handler);
 
-#ifdef USE_WEBSERVER_OTA
-  void add_ota_handler();
-#endif
-
   void set_port(uint16_t port) { port_ = port; }
   uint16_t get_port() const { return port_; }
 
  protected:
-#ifdef USE_WEBSERVER_OTA
-  friend class OTARequestHandler;
-#endif
-
   int initialized_{0};
   uint16_t port_{80};
   std::shared_ptr server_{nullptr};
@@ -129,37 +124,6 @@ class WebServerBase : public Component {
   internal::Credentials credentials_;
 };
 
-#ifdef USE_WEBSERVER_OTA
-class OTARequestHandler : public AsyncWebHandler {
- public:
-  OTARequestHandler(WebServerBase *parent) : parent_(parent) {}
-  void handleRequest(AsyncWebServerRequest *request) override;
-  void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
-                    bool final) override;
-  bool canHandle(AsyncWebServerRequest *request) const override {
-    return request->url() == "/update" && request->method() == HTTP_POST;
-  }
-
-  // NOLINTNEXTLINE(readability-identifier-naming)
-  bool isRequestHandlerTrivial() const override { return false; }
-
- protected:
-  void report_ota_progress_(AsyncWebServerRequest *request);
-  void schedule_ota_reboot_();
-  void ota_init_(const char *filename);
-
-  uint32_t last_ota_progress_{0};
-  uint32_t ota_read_length_{0};
-  WebServerBase *parent_;
-
- private:
-#ifdef USE_ESP_IDF
-  void *ota_backend_{nullptr};
-  bool ota_success_{false};
-#endif
-};
-#endif  // USE_WEBSERVER_OTA
-
 }  // namespace web_server_base
 }  // namespace esphome
 #endif
diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py
index fe1c6f2640..506e1c5c13 100644
--- a/esphome/components/web_server_idf/__init__.py
+++ b/esphome/components/web_server_idf/__init__.py
@@ -1,7 +1,5 @@
-from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
+from esphome.components.esp32 import add_idf_sdkconfig_option
 import esphome.config_validation as cv
-from esphome.const import CONF_OTA, CONF_WEB_SERVER
-from esphome.core import CORE
 
 CODEOWNERS = ["@dentra"]
 
@@ -14,7 +12,3 @@ CONFIG_SCHEMA = cv.All(
 async def to_code(config):
     # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server
     add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024)
-    # Check if web_server component has OTA enabled
-    if CORE.config.get(CONF_WEB_SERVER, {}).get(CONF_OTA, True):
-        # Add multipart parser component for ESP-IDF OTA support
-        add_idf_component(name="zorxx/multipart-parser", ref="1.0.1")
diff --git a/esphome/config.py b/esphome/config.py
index 73cc7657cc..c4aa9aea24 100644
--- a/esphome/config.py
+++ b/esphome/config.py
@@ -67,6 +67,42 @@ ConfigPath = list[str | int]
 path_context = contextvars.ContextVar("Config path")
 
 
+def _process_platform_config(
+    result: Config,
+    component_name: str,
+    platform_name: str,
+    platform_config: ConfigType,
+    path: ConfigPath,
+) -> None:
+    """Process a platform configuration and add necessary validation steps.
+
+    This is shared between LoadValidationStep and AutoLoadValidationStep to avoid duplication.
+    """
+    # Get the platform manifest
+    platform = get_platform(component_name, platform_name)
+    if platform is None:
+        result.add_str_error(
+            f"Platform not found: '{component_name}.{platform_name}'", path
+        )
+        return
+
+    # Add platform to loaded integrations
+    CORE.loaded_integrations.add(platform_name)
+    CORE.loaded_platforms.add(f"{component_name}/{platform_name}")
+
+    # Process platform's AUTO_LOAD
+    for load in platform.auto_load:
+        if load not in result:
+            result.add_validation_step(AutoLoadValidationStep(load))
+
+    # Add validation steps for the platform
+    p_domain = f"{component_name}.{platform_name}"
+    result.add_output_path(path, p_domain)
+    result.add_validation_step(
+        MetadataValidationStep(path, p_domain, platform_config, platform)
+    )
+
+
 def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool:
     if len(path) < len(other):
         return False
@@ -379,26 +415,11 @@ class LoadValidationStep(ConfigValidationStep):
                     path,
                 )
                 continue
-            # Remove temp output path and construct new one
+            # Remove temp output path
             result.remove_output_path(path, p_domain)
-            p_domain = f"{self.domain}.{p_name}"
-            result.add_output_path(path, p_domain)
-            # Try Load platform
-            platform = get_platform(self.domain, p_name)
-            if platform is None:
-                result.add_str_error(f"Platform not found: '{p_domain}'", path)
-                continue
-            CORE.loaded_integrations.add(p_name)
-            CORE.loaded_platforms.add(f"{self.domain}/{p_name}")
 
-            # Process AUTO_LOAD
-            for load in platform.auto_load:
-                if load not in result:
-                    result.add_validation_step(AutoLoadValidationStep(load))
-
-            result.add_validation_step(
-                MetadataValidationStep(path, p_domain, p_config, platform)
-            )
+            # Process the platform configuration
+            _process_platform_config(result, self.domain, p_name, p_config, path)
 
 
 class AutoLoadValidationStep(ConfigValidationStep):
@@ -413,10 +434,56 @@ class AutoLoadValidationStep(ConfigValidationStep):
         self.domain = domain
 
     def run(self, result: Config) -> None:
-        if self.domain in result:
-            # already loaded
+        # Regular component auto-load (no platform)
+        if "." not in self.domain:
+            if self.domain in result:
+                # already loaded
+                return
+            result.add_validation_step(LoadValidationStep(self.domain, core.AutoLoad()))
             return
-        result.add_validation_step(LoadValidationStep(self.domain, core.AutoLoad()))
+
+        # Platform-specific auto-load (e.g., "ota.web_server")
+        component_name, _, platform_name = self.domain.partition(".")
+
+        # Check if component exists
+        if component_name not in result:
+            # Component doesn't exist, load it first
+            result.add_validation_step(LoadValidationStep(component_name, []))
+            # Re-run this step after the component is loaded
+            result.add_validation_step(AutoLoadValidationStep(self.domain))
+            return
+
+        # Component exists, check if it's a platform component
+        component = get_component(component_name)
+        if component is None or not component.is_platform_component:
+            result.add_str_error(
+                f"Component {component_name} is not a platform component, "
+                f"cannot auto-load platform {platform_name}",
+                [component_name],
+            )
+            return
+
+        # Ensure the component config is a list
+        component_conf = result.get(component_name)
+        if not isinstance(component_conf, list):
+            component_conf = result[component_name] = []
+
+        # Check if platform already exists
+        if any(
+            isinstance(conf, dict) and conf.get(CONF_PLATFORM) == platform_name
+            for conf in component_conf
+        ):
+            return
+
+        # Add and process the platform configuration
+        platform_conf = core.AutoLoad()
+        platform_conf[CONF_PLATFORM] = platform_name
+        component_conf.append(platform_conf)
+
+        path = [component_name, len(component_conf) - 1]
+        _process_platform_config(
+            result, component_name, platform_name, platform_conf, path
+        )
 
 
 class MetadataValidationStep(ConfigValidationStep):
diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py
new file mode 100644
index 0000000000..0d8ff6f134
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota.py
@@ -0,0 +1,102 @@
+"""Tests for the web_server OTA platform."""
+
+from collections.abc import Callable
+
+
+def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None:
+    """Test that web_server OTA platform generates correct code."""
+    main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota.yaml")
+
+    # Check that the web server OTA component is included
+    assert "WebServerOTAComponent" in main_cpp
+    assert "web_server::WebServerOTAComponent" in main_cpp
+
+    # Check that global web server base is referenced
+    assert "global_web_server_base" in main_cpp
+
+    # Check component is registered
+    assert "App.register_component(web_server_webserverotacomponent_id)" in main_cpp
+
+
+def test_web_server_ota_with_callbacks(generate_main: Callable[[str], str]) -> None:
+    """Test web_server OTA with state callbacks."""
+    main_cpp = generate_main(
+        "tests/component_tests/ota/test_web_server_ota_callbacks.yaml"
+    )
+
+    # Check that web server OTA component is present
+    assert "WebServerOTAComponent" in main_cpp
+
+    # Check that callbacks are configured
+    # The actual callback code is in the component implementation, not main.cpp
+    # But we can check that logger.log statements are present from the callbacks
+    assert "logger.log" in main_cpp
+    assert "OTA started" in main_cpp
+    assert "OTA completed" in main_cpp
+    assert "OTA error" in main_cpp
+
+
+def test_web_server_ota_idf_multipart(generate_main: Callable[[str], str]) -> None:
+    """Test that ESP-IDF builds include multipart parser dependency."""
+    main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota_idf.yaml")
+
+    # Check that web server OTA component is present
+    assert "WebServerOTAComponent" in main_cpp
+
+    # For ESP-IDF builds, the framework type is esp-idf
+    # The multipart parser dependency is added by web_server_idf
+    assert "web_server::WebServerOTAComponent" in main_cpp
+
+
+def test_web_server_ota_without_web_server_fails(
+    generate_main: Callable[[str], str],
+) -> None:
+    """Test that web_server OTA requires web_server component."""
+    # This should fail during validation since web_server_base is required
+    # but we can't test validation failures with generate_main
+    # Instead, verify that both components are needed in valid config
+    main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota.yaml")
+
+    # Both web server and OTA components should be present
+    assert "WebServer" in main_cpp
+    assert "WebServerOTAComponent" in main_cpp
+
+
+def test_multiple_ota_platforms(generate_main: Callable[[str], str]) -> None:
+    """Test multiple OTA platforms can coexist."""
+    main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota_multi.yaml")
+
+    # Check all OTA platforms are included
+    assert "WebServerOTAComponent" in main_cpp
+    assert "ESPHomeOTAComponent" in main_cpp
+    assert "OtaHttpRequestComponent" in main_cpp
+
+    # Check components are from correct namespaces
+    assert "web_server::WebServerOTAComponent" in main_cpp
+    assert "esphome::ESPHomeOTAComponent" in main_cpp
+    assert "http_request::OtaHttpRequestComponent" in main_cpp
+
+
+def test_web_server_ota_arduino_with_auth(generate_main: Callable[[str], str]) -> None:
+    """Test web_server OTA with Arduino framework and authentication."""
+    main_cpp = generate_main(
+        "tests/component_tests/ota/test_web_server_ota_arduino.yaml"
+    )
+
+    # Check web server OTA component is present
+    assert "WebServerOTAComponent" in main_cpp
+
+    # Check authentication is set up for web server
+    assert "set_auth_username" in main_cpp
+    assert "set_auth_password" in main_cpp
+
+
+def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None:
+    """Test web_server OTA on ESP8266 platform."""
+    main_cpp = generate_main(
+        "tests/component_tests/ota/test_web_server_ota_esp8266.yaml"
+    )
+
+    # Check web server OTA component is present
+    assert "WebServerOTAComponent" in main_cpp
+    assert "web_server::WebServerOTAComponent" in main_cpp
diff --git a/tests/component_tests/ota/test_web_server_ota.yaml b/tests/component_tests/ota/test_web_server_ota.yaml
new file mode 100644
index 0000000000..e0fda3d0b5
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota.yaml
@@ -0,0 +1,15 @@
+esphome:
+  name: test_web_server_ota
+
+esp32:
+  board: esp32dev
+
+wifi:
+  ssid: MySSID
+  password: password1
+
+web_server:
+  port: 80
+
+ota:
+  - platform: web_server
diff --git a/tests/component_tests/ota/test_web_server_ota_arduino.yaml b/tests/component_tests/ota/test_web_server_ota_arduino.yaml
new file mode 100644
index 0000000000..9462548cc8
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota_arduino.yaml
@@ -0,0 +1,18 @@
+esphome:
+  name: test_web_server_ota_arduino
+
+esp32:
+  board: esp32dev
+
+wifi:
+  ssid: MySSID
+  password: password1
+
+web_server:
+  port: 80
+  auth:
+    username: admin
+    password: admin
+
+ota:
+  - platform: web_server
diff --git a/tests/component_tests/ota/test_web_server_ota_callbacks.yaml b/tests/component_tests/ota/test_web_server_ota_callbacks.yaml
new file mode 100644
index 0000000000..c2fd9e0f19
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota_callbacks.yaml
@@ -0,0 +1,31 @@
+esphome:
+  name: test_web_server_ota_callbacks
+
+esp32:
+  board: esp32dev
+
+wifi:
+  ssid: MySSID
+  password: password1
+
+logger:
+
+web_server:
+  port: 80
+
+ota:
+  - platform: web_server
+    on_begin:
+      - logger.log: "OTA started"
+    on_progress:
+      - logger.log:
+          format: "OTA progress: %.1f%%"
+          args: ["x"]
+    on_end:
+      - logger.log: "OTA completed"
+    on_error:
+      - logger.log:
+          format: "OTA error: %d"
+          args: ["x"]
+    on_state_change:
+      - logger.log: "OTA state changed"
diff --git a/tests/component_tests/ota/test_web_server_ota_esp8266.yaml b/tests/component_tests/ota/test_web_server_ota_esp8266.yaml
new file mode 100644
index 0000000000..a1b66a5b53
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota_esp8266.yaml
@@ -0,0 +1,15 @@
+esphome:
+  name: test_web_server_ota_esp8266
+
+esp8266:
+  board: nodemcuv2
+
+wifi:
+  ssid: MySSID
+  password: password1
+
+web_server:
+  port: 80
+
+ota:
+  - platform: web_server
diff --git a/tests/component_tests/ota/test_web_server_ota_idf.yaml b/tests/component_tests/ota/test_web_server_ota_idf.yaml
new file mode 100644
index 0000000000..18b639347c
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota_idf.yaml
@@ -0,0 +1,17 @@
+esphome:
+  name: test_web_server_ota_idf
+
+esp32:
+  board: esp32dev
+  framework:
+    type: esp-idf
+
+wifi:
+  ssid: MySSID
+  password: password1
+
+web_server:
+  port: 80
+
+ota:
+  - platform: web_server
diff --git a/tests/component_tests/ota/test_web_server_ota_multi.yaml b/tests/component_tests/ota/test_web_server_ota_multi.yaml
new file mode 100644
index 0000000000..7926b09c71
--- /dev/null
+++ b/tests/component_tests/ota/test_web_server_ota_multi.yaml
@@ -0,0 +1,21 @@
+esphome:
+  name: test_web_server_ota_multi
+
+esp32:
+  board: esp32dev
+
+wifi:
+  ssid: MySSID
+  password: password1
+
+web_server:
+  port: 80
+
+http_request:
+  verify_ssl: false
+
+ota:
+  - platform: esphome
+    password: "test_password"
+  - platform: web_server
+  - platform: http_request
diff --git a/tests/component_tests/web_server/test_ota_migration.py b/tests/component_tests/web_server/test_ota_migration.py
new file mode 100644
index 0000000000..7f34ec75f6
--- /dev/null
+++ b/tests/component_tests/web_server/test_ota_migration.py
@@ -0,0 +1,38 @@
+"""Tests for web_server OTA migration validation."""
+
+import pytest
+
+from esphome import config_validation as cv
+from esphome.types import ConfigType
+
+
+def test_web_server_ota_true_fails_validation() -> None:
+    """Test that web_server with ota: true fails validation with helpful message."""
+    from esphome.components.web_server import validate_ota_removed
+
+    # Config with ota: true should fail
+    config: ConfigType = {"ota": True}
+
+    with pytest.raises(cv.Invalid) as exc_info:
+        validate_ota_removed(config)
+
+    # Check error message contains migration instructions
+    error_msg = str(exc_info.value)
+    assert "has been removed from 'web_server'" in error_msg
+    assert "platform: web_server" in error_msg
+    assert "ota:" in error_msg
+
+
+def test_web_server_ota_false_passes_validation() -> None:
+    """Test that web_server with ota: false passes validation."""
+    from esphome.components.web_server import validate_ota_removed
+
+    # Config with ota: false should pass
+    config: ConfigType = {"ota": False}
+    result = validate_ota_removed(config)
+    assert result == config
+
+    # Config without ota should also pass
+    config: ConfigType = {}
+    result = validate_ota_removed(config)
+    assert result == config
diff --git a/tests/components/web_server/test_no_ota.esp32-idf.yaml b/tests/components/web_server/test_no_ota.esp32-idf.yaml
index 1f677fb948..4064f518cf 100644
--- a/tests/components/web_server/test_no_ota.esp32-idf.yaml
+++ b/tests/components/web_server/test_no_ota.esp32-idf.yaml
@@ -1,3 +1,11 @@
+esphome:
+  name: test-web-server-no-ota-idf
+
+esp32:
+  board: esp32dev
+  framework:
+    type: esp-idf
+
 packages:
   device_base: !include common.yaml
 
@@ -6,4 +14,3 @@ packages:
 web_server:
   port: 8080
   version: 2
-  ota: false
diff --git a/tests/components/web_server/test_ota.esp32-idf.yaml b/tests/components/web_server/test_ota.esp32-idf.yaml
index 294e7f862e..37838b3d34 100644
--- a/tests/components/web_server/test_ota.esp32-idf.yaml
+++ b/tests/components/web_server/test_ota.esp32-idf.yaml
@@ -1,8 +1,6 @@
-# Test configuration for ESP-IDF web server with OTA enabled
 esphome:
   name: test-web-server-ota-idf
 
-# Force ESP-IDF framework
 esp32:
   board: esp32dev
   framework:
@@ -15,17 +13,17 @@ packages:
 ota:
   - platform: esphome
     password: "test_ota_password"
+  - platform: web_server
 
-# Web server with OTA enabled
+# Web server configuration
 web_server:
   port: 8080
   version: 2
-  ota: true
   include_internal: true
 
 # Enable debug logging for OTA
 logger:
-  level: DEBUG
+  level: VERBOSE
   logs:
     web_server: VERBOSE
     web_server_idf: VERBOSE
diff --git a/tests/components/web_server/test_ota_disabled.esp32-idf.yaml b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml
index c7c7574e3b..b88b845db7 100644
--- a/tests/components/web_server/test_ota_disabled.esp32-idf.yaml
+++ b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml
@@ -1,11 +1,18 @@
+esphome:
+  name: test-ws-ota-disabled-idf
+
+esp32:
+  board: esp32dev
+  framework:
+    type: esp-idf
+
 packages:
   device_base: !include common.yaml
 
-# OTA is configured but web_server OTA is disabled
+# OTA is configured but web_server OTA is NOT included
 ota:
   - platform: esphome
 
 web_server:
   port: 8080
   version: 2
-  ota: false

From 785b14ac84fa0cacc88af0923c36170ea3a07518 Mon Sep 17 00:00:00 2001
From: George 
Date: Wed, 2 Jul 2025 04:14:16 +0200
Subject: [PATCH 04/17] pulse_meter total (#9282)

---
 esphome/components/pulse_meter/pulse_meter_sensor.cpp | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp
index 81ecf22c71..9a7630a7be 100644
--- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp
+++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp
@@ -31,6 +31,10 @@ void PulseMeterSensor::setup() {
     this->pulse_state_.latched_ = this->last_pin_val_;
     this->pin_->attach_interrupt(PulseMeterSensor::pulse_intr, this, gpio::INTERRUPT_ANY_EDGE);
   }
+
+  if (this->total_sensor_ != nullptr) {
+    this->total_sensor_->publish_state(this->total_pulses_);
+  }
 }
 
 void PulseMeterSensor::loop() {

From 5fa9d22c5dd925bd92eaf3297c25b33e8d3abff6 Mon Sep 17 00:00:00 2001
From: Craig Andrews 
Date: Tue, 1 Jul 2025 22:17:34 -0400
Subject: [PATCH 05/17] [http_request] allow retrieval of more than just the
 first header (#9242)

---
 esphome/components/http_request/http_request_arduino.cpp | 1 -
 esphome/components/http_request/http_request_idf.cpp     | 1 -
 2 files changed, 2 deletions(-)

diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp
index b4378cdce6..c009b33c2d 100644
--- a/esphome/components/http_request/http_request_arduino.cpp
+++ b/esphome/components/http_request/http_request_arduino.cpp
@@ -133,7 +133,6 @@ std::shared_ptr HttpRequestArduino::perform(std::string url, std:
       std::string header_value = container->client_.header(i).c_str();
       ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
       container->response_headers_[header_name].push_back(header_value);
-      break;
     }
   }
 
diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp
index 6a779ba03a..68c06d28f2 100644
--- a/esphome/components/http_request/http_request_idf.cpp
+++ b/esphome/components/http_request/http_request_idf.cpp
@@ -42,7 +42,6 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
         const std::string header_value = evt->header_value;
         ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
         user_data->response_headers[header_name].push_back(header_value);
-        break;
       }
       break;
     }

From 095acce3e28ccec918968d5af44af1edd85b4a48 Mon Sep 17 00:00:00 2001
From: Jeremy Brown 
Date: Tue, 1 Jul 2025 22:48:42 -0400
Subject: [PATCH 06/17] Mmc5603 fix for devices that don't retrieve chip_id
 (#8959)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
---
 esphome/components/mmc5603/mmc5603.cpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp
index 86b1b23c15..7f78f9592a 100644
--- a/esphome/components/mmc5603/mmc5603.cpp
+++ b/esphome/components/mmc5603/mmc5603.cpp
@@ -39,7 +39,7 @@ void MMC5603Component::setup() {
     return;
   }
 
-  if (id != MMC56X3_CHIP_ID) {
+  if (id != 0 && id != MMC56X3_CHIP_ID) {  // ID is not reported correctly by all chips, 0 on some chips
     ESP_LOGCONFIG(TAG, "Chip Wrong");
     this->error_code_ = ID_REGISTERS;
     this->mark_failed();

From 2fb23becec8726cbcace94951814debf7f872775 Mon Sep 17 00:00:00 2001
From: JonasB2497 <45214989+JonasB2497@users.noreply.github.com>
Date: Wed, 2 Jul 2025 04:56:48 +0200
Subject: [PATCH 07/17] made qr_code elements optional (#8896)

---
 esphome/components/qr_code/__init__.py | 23 +++++++++++++----------
 1 file changed, 13 insertions(+), 10 deletions(-)

diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py
index 1c5e0471b0..6ff92b8a7f 100644
--- a/esphome/components/qr_code/__init__.py
+++ b/esphome/components/qr_code/__init__.py
@@ -21,21 +21,24 @@ ECC = {
     "HIGH": qrcodegen_Ecc.qrcodegen_Ecc_HIGH,
 }
 
-CONFIG_SCHEMA = cv.Schema(
-    {
-        cv.Required(CONF_ID): cv.declare_id(QRCode),
-        cv.Required(CONF_VALUE): cv.string,
-        cv.Optional(CONF_ECC, default="LOW"): cv.enum(ECC, upper=True),
-    }
+CONFIG_SCHEMA = cv.ensure_list(
+    cv.Schema(
+        {
+            cv.Required(CONF_ID): cv.declare_id(QRCode),
+            cv.Required(CONF_VALUE): cv.string,
+            cv.Optional(CONF_ECC, default="LOW"): cv.enum(ECC, upper=True),
+        }
+    )
 )
 
 
 async def to_code(config):
     cg.add_library("wjtje/qr-code-generator-library", "^1.7.0")
 
-    var = cg.new_Pvariable(config[CONF_ID])
-    cg.add(var.set_value(config[CONF_VALUE]))
-    cg.add(var.set_ecc(ECC[config[CONF_ECC]]))
-    await cg.register_component(var, config)
+    for entry in config:
+        var = cg.new_Pvariable(entry[CONF_ID])
+        cg.add(var.set_value(entry[CONF_VALUE]))
+        cg.add(var.set_ecc(ECC[entry[CONF_ECC]]))
+        await cg.register_component(var, entry)
 
     cg.add_define("USE_QR_CODE")

From fae96e279c810401cc3e751dca062d30399ac71d Mon Sep 17 00:00:00 2001
From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com>
Date: Wed, 2 Jul 2025 05:25:06 +0200
Subject: [PATCH 08/17] [nextion] memory optimization (#9164)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
---
 esphome/components/nextion/nextion.cpp        | 25 ++++++++-----------
 esphome/components/nextion/nextion.h          | 16 ++++++------
 .../components/nextion/nextion_commands.cpp   |  4 +--
 3 files changed, 20 insertions(+), 25 deletions(-)

diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp
index 042a595ff8..bb75385d8c 100644
--- a/esphome/components/nextion/nextion.cpp
+++ b/esphome/components/nextion/nextion.cpp
@@ -164,7 +164,7 @@ void Nextion::dump_config() {
 #endif  // USE_NEXTION_MAX_COMMANDS_PER_LOOP
 
   if (this->touch_sleep_timeout_ != 0) {
-    ESP_LOGCONFIG(TAG, "  Touch Timeout:  %" PRIu32, this->touch_sleep_timeout_);
+    ESP_LOGCONFIG(TAG, "  Touch Timeout:  %" PRIu16, this->touch_sleep_timeout_);
   }
 
   if (this->wake_up_page_ != -1) {
@@ -302,11 +302,11 @@ void Nextion::loop() {
     }
 
     // Check if a startup page has been set and send the command
-    if (this->start_up_page_ != -1) {
+    if (this->start_up_page_ >= 0) {
       this->goto_page(this->start_up_page_);
     }
 
-    if (this->wake_up_page_ != -1) {
+    if (this->wake_up_page_ >= 0) {
       this->set_wake_up_page(this->wake_up_page_);
     }
 
@@ -418,12 +418,12 @@ void Nextion::process_nextion_commands_() {
       ESP_LOGN(TAG, "Add 0xFF");
     }
 
-    this->nextion_event_ = this->command_data_[0];
+    const uint8_t nextion_event = this->command_data_[0];
 
     to_process_length -= 1;
     to_process = this->command_data_.substr(1, to_process_length);
 
-    switch (this->nextion_event_) {
+    switch (nextion_event) {
       case 0x00:  // instruction sent by user has failed
         ESP_LOGW(TAG, "Invalid instruction");
         this->remove_from_q_();
@@ -562,9 +562,9 @@ void Nextion::process_nextion_commands_() {
           break;
         }
 
-        uint16_t x = (uint16_t(to_process[0]) << 8) | to_process[1];
-        uint16_t y = (uint16_t(to_process[2]) << 8) | to_process[3];
-        uint8_t touch_event = to_process[4];  // 0 -> release, 1 -> press
+        const uint16_t x = (uint16_t(to_process[0]) << 8) | to_process[1];
+        const uint16_t y = (uint16_t(to_process[2]) << 8) | to_process[3];
+        const uint8_t touch_event = to_process[4];  // 0 -> release, 1 -> press
         ESP_LOGD(TAG, "Touch %s at %u,%u", touch_event ? "PRESS" : "RELEASE", x, y);
         break;
       }
@@ -820,15 +820,14 @@ void Nextion::process_nextion_commands_() {
         break;
       }
       default:
-        ESP_LOGW(TAG, "Unknown event: 0x%02X", this->nextion_event_);
+        ESP_LOGW(TAG, "Unknown event: 0x%02X", nextion_event);
         break;
     }
 
-    // ESP_LOGN(TAG, "nextion_event_ deleting from 0 to %d", to_process_length + COMMAND_DELIMITER.length() + 1);
     this->command_data_.erase(0, to_process_length + COMMAND_DELIMITER.length() + 1);
   }
 
-  uint32_t ms = App.get_loop_component_start_time();
+  const uint32_t ms = App.get_loop_component_start_time();
 
   if (!this->nextion_queue_.empty() && this->nextion_queue_.front()->queue_time + this->max_q_age_ms_ < ms) {
     for (size_t i = 0; i < this->nextion_queue_.size(); i++) {
@@ -960,7 +959,6 @@ void Nextion::update_components_by_prefix(const std::string &prefix) {
 }
 
 uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag) {
-  uint16_t ret = 0;
   uint8_t c = 0;
   uint8_t nr_of_ff_bytes = 0;
   bool exit_flag = false;
@@ -1003,8 +1001,7 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool
   if (ff_flag)
     response = response.substr(0, response.length() - 3);  // Remove last 3 0xFF
 
-  ret = response.length();
-  return ret;
+  return response.length();
 }
 
 /**
diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h
index 0cd559d251..0b77d234f5 100644
--- a/esphome/components/nextion/nextion.h
+++ b/esphome/components/nextion/nextion.h
@@ -1190,11 +1190,11 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
    * After 30 seconds the display will go to sleep. Note: the display will only wakeup by a restart or by setting up
    * `thup`.
    */
-  void set_touch_sleep_timeout(uint32_t touch_sleep_timeout);
+  void set_touch_sleep_timeout(uint16_t touch_sleep_timeout);
 
   /**
    * Sets which page Nextion loads when exiting sleep mode. Note this can be set even when Nextion is in sleep mode.
-   * @param wake_up_page The page id, from 0 to the lage page in Nextion. Set 255 (not set to any existing page) to
+   * @param wake_up_page The page id, from 0 to the last page in Nextion. Set -1 (not set to any existing page) to
    * wakes up to current page.
    *
    * Example:
@@ -1204,11 +1204,11 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
    *
    * The display will wake up to page 2.
    */
-  void set_wake_up_page(uint8_t wake_up_page = 255);
+  void set_wake_up_page(int16_t wake_up_page = -1);
 
   /**
    * Sets which page Nextion loads when connecting to ESPHome.
-   * @param start_up_page The page id, from 0 to the lage page in Nextion. Set 255 (not set to any existing page) to
+   * @param start_up_page The page id, from 0 to the last page in Nextion. Set -1 (not set to any existing page) to
    * wakes up to current page.
    *
    * Example:
@@ -1218,7 +1218,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
    *
    * The display will go to page 2 when it establishes a connection to ESPHome.
    */
-  void set_start_up_page(uint8_t start_up_page = 255) { this->start_up_page_ = start_up_page; }
+  void set_start_up_page(int16_t start_up_page = -1) { this->start_up_page_ = start_up_page; }
 
   /**
    * Sets if Nextion should auto-wake from sleep when touch press occurs.
@@ -1330,7 +1330,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
   std::deque waveform_queue_;
   uint16_t recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag);
   void all_components_send_state_(bool force_update = false);
-  uint64_t comok_sent_ = 0;
+  uint32_t comok_sent_ = 0;
   bool remove_from_q_(bool report_empty = true);
 
   /**
@@ -1340,12 +1340,10 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
   bool ignore_is_setup_ = false;
 
   bool nextion_reports_is_setup_ = false;
-  uint8_t nextion_event_;
-
   void process_nextion_commands_();
   void process_serial_();
   bool is_updating_ = false;
-  uint32_t touch_sleep_timeout_ = 0;
+  uint16_t touch_sleep_timeout_ = 0;
   int16_t wake_up_page_ = -1;
   int16_t start_up_page_ = -1;
   bool auto_wake_on_touch_ = true;
diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp
index 0226e0a13c..84aacd1868 100644
--- a/esphome/components/nextion/nextion_commands.cpp
+++ b/esphome/components/nextion/nextion_commands.cpp
@@ -10,12 +10,12 @@ static const char *const TAG = "nextion";
 // Sleep safe commands
 void Nextion::soft_reset() { this->send_command_("rest"); }
 
-void Nextion::set_wake_up_page(uint8_t wake_up_page) {
+void Nextion::set_wake_up_page(int16_t wake_up_page) {
   this->wake_up_page_ = wake_up_page;
   this->add_no_result_to_queue_with_set_internal_("wake_up_page", "wup", wake_up_page, true);
 }
 
-void Nextion::set_touch_sleep_timeout(uint32_t touch_sleep_timeout) {
+void Nextion::set_touch_sleep_timeout(uint16_t touch_sleep_timeout) {
   if (touch_sleep_timeout < 3) {
     ESP_LOGD(TAG, "Sleep timeout out of bounds (3-65535)");
     return;

From eba2c82fec60181327afa35a3be48613d18cc35b Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" 
Date: Tue, 1 Jul 2025 23:36:09 -0500
Subject: [PATCH 09/17] Use encode_bytes() for protobuf bytes fields (#9289)

---
 esphome/components/api/api_pb2.cpp  | 24 +++++++++++++-----------
 script/api_protobuf/api_protobuf.py |  6 +++++-
 2 files changed, 18 insertions(+), 12 deletions(-)

diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp
index 8bce14c9cc..7d16e43ce6 100644
--- a/esphome/components/api/api_pb2.cpp
+++ b/esphome/components/api/api_pb2.cpp
@@ -3494,7 +3494,7 @@ bool SubscribeLogsResponse::decode_length(uint32_t field_id, ProtoLengthDelimite
 }
 void SubscribeLogsResponse::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_enum(1, this->level);
-  buffer.encode_string(3, this->message);
+  buffer.encode_bytes(3, reinterpret_cast(this->message.data()), this->message.size());
   buffer.encode_bool(4, this->send_failed);
 }
 void SubscribeLogsResponse::calculate_size(uint32_t &total_size) const {
@@ -3530,7 +3530,9 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD
       return false;
   }
 }
-void NoiseEncryptionSetKeyRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key); }
+void NoiseEncryptionSetKeyRequest::encode(ProtoWriteBuffer buffer) const {
+  buffer.encode_bytes(1, reinterpret_cast(this->key.data()), this->key.size());
+}
 void NoiseEncryptionSetKeyRequest::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_string_field(total_size, 1, this->key, false);
 }
@@ -4267,7 +4269,7 @@ bool CameraImageResponse::decode_32bit(uint32_t field_id, Proto32Bit value) {
 }
 void CameraImageResponse::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_fixed32(1, this->key);
-  buffer.encode_string(2, this->data);
+  buffer.encode_bytes(2, reinterpret_cast(this->data.data()), this->data.size());
   buffer.encode_bool(3, this->done);
 }
 void CameraImageResponse::calculate_size(uint32_t &total_size) const {
@@ -6785,7 +6787,7 @@ void BluetoothServiceData::encode(ProtoWriteBuffer buffer) const {
   for (auto &it : this->legacy_data) {
     buffer.encode_uint32(2, it, true);
   }
-  buffer.encode_string(3, this->data);
+  buffer.encode_bytes(3, reinterpret_cast(this->data.data()), this->data.size());
 }
 void BluetoothServiceData::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_string_field(total_size, 1, this->uuid, false);
@@ -6859,7 +6861,7 @@ bool BluetoothLEAdvertisementResponse::decode_length(uint32_t field_id, ProtoLen
 }
 void BluetoothLEAdvertisementResponse::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_uint64(1, this->address);
-  buffer.encode_string(2, this->name);
+  buffer.encode_bytes(2, reinterpret_cast(this->name.data()), this->name.size());
   buffer.encode_sint32(3, this->rssi);
   for (auto &it : this->service_uuids) {
     buffer.encode_string(4, it, true);
@@ -6960,7 +6962,7 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_uint64(1, this->address);
   buffer.encode_sint32(2, this->rssi);
   buffer.encode_uint32(3, this->address_type);
-  buffer.encode_string(4, this->data);
+  buffer.encode_bytes(4, reinterpret_cast(this->data.data()), this->data.size());
 }
 void BluetoothLERawAdvertisement::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_uint64_field(total_size, 1, this->address, false);
@@ -7493,7 +7495,7 @@ bool BluetoothGATTReadResponse::decode_length(uint32_t field_id, ProtoLengthDeli
 void BluetoothGATTReadResponse::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_uint64(1, this->address);
   buffer.encode_uint32(2, this->handle);
-  buffer.encode_string(3, this->data);
+  buffer.encode_bytes(3, reinterpret_cast(this->data.data()), this->data.size());
 }
 void BluetoothGATTReadResponse::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_uint64_field(total_size, 1, this->address, false);
@@ -7552,7 +7554,7 @@ void BluetoothGATTWriteRequest::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_uint64(1, this->address);
   buffer.encode_uint32(2, this->handle);
   buffer.encode_bool(3, this->response);
-  buffer.encode_string(4, this->data);
+  buffer.encode_bytes(4, reinterpret_cast(this->data.data()), this->data.size());
 }
 void BluetoothGATTWriteRequest::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_uint64_field(total_size, 1, this->address, false);
@@ -7649,7 +7651,7 @@ bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, Proto
 void BluetoothGATTWriteDescriptorRequest::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_uint64(1, this->address);
   buffer.encode_uint32(2, this->handle);
-  buffer.encode_string(3, this->data);
+  buffer.encode_bytes(3, reinterpret_cast(this->data.data()), this->data.size());
 }
 void BluetoothGATTWriteDescriptorRequest::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_uint64_field(total_size, 1, this->address, false);
@@ -7751,7 +7753,7 @@ bool BluetoothGATTNotifyDataResponse::decode_length(uint32_t field_id, ProtoLeng
 void BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_uint64(1, this->address);
   buffer.encode_uint32(2, this->handle);
-  buffer.encode_string(3, this->data);
+  buffer.encode_bytes(3, reinterpret_cast(this->data.data()), this->data.size());
 }
 void BluetoothGATTNotifyDataResponse::calculate_size(uint32_t &total_size) const {
   ProtoSize::add_uint64_field(total_size, 1, this->address, false);
@@ -8481,7 +8483,7 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited
   }
 }
 void VoiceAssistantAudio::encode(ProtoWriteBuffer buffer) const {
-  buffer.encode_string(1, this->data);
+  buffer.encode_bytes(1, reinterpret_cast(this->data.data()), this->data.size());
   buffer.encode_bool(2, this->end);
 }
 void VoiceAssistantAudio::calculate_size(uint32_t &total_size) const {
diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py
index 615f5bbfda..56a46a7701 100755
--- a/script/api_protobuf/api_protobuf.py
+++ b/script/api_protobuf/api_protobuf.py
@@ -526,9 +526,13 @@ class BytesType(TypeInfo):
     reference_type = "std::string &"
     const_reference_type = "const std::string &"
     decode_length = "value.as_string()"
-    encode_func = "encode_string"
+    encode_func = "encode_bytes"
     wire_type = WireType.LENGTH_DELIMITED  # Uses wire type 2
 
+    @property
+    def encode_content(self) -> str:
+        return f"buffer.encode_bytes({self.number}, reinterpret_cast(this->{self.field_name}.data()), this->{self.field_name}.size());"
+
     def dump(self, name: str) -> str:
         o = f"out.append(format_hex_pretty({name}));"
         return o

From f6f0e52d5e7bdef9e2b36990307779d6624a5f36 Mon Sep 17 00:00:00 2001
From: Aleksey Zinchenko 
Date: Wed, 2 Jul 2025 10:37:31 +0300
Subject: [PATCH 10/17] [core] Deleting CMakeCache.txt for fast recompilation
 with ESP-IDF (#8750)

---
 esphome/writer.py | 21 ++++++++++++++++++---
 1 file changed, 18 insertions(+), 3 deletions(-)

diff --git a/esphome/writer.py b/esphome/writer.py
index 7a5089e384..943dfa78cc 100644
--- a/esphome/writer.py
+++ b/esphome/writer.py
@@ -107,6 +107,11 @@ def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool:
         return True
     if old.build_path != new.build_path:
         return True
+
+    return False
+
+
+def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool:
     if (
         old.loaded_integrations != new.loaded_integrations
         or old.loaded_platforms != new.loaded_platforms
@@ -126,10 +131,11 @@ def update_storage_json():
         return
 
     if storage_should_clean(old, new):
-        _LOGGER.info(
-            "Core config, version or integrations changed, cleaning build files..."
-        )
+        _LOGGER.info("Core config, version changed, cleaning build files...")
         clean_build()
+    elif storage_should_update_cmake_cache(old, new):
+        _LOGGER.info("Integrations changed, cleaning cmake cache...")
+        clean_cmake_cache()
 
     new.save(path)
 
@@ -353,6 +359,15 @@ def write_cpp(code_s):
     write_file_if_changed(path, full_file)
 
 
+def clean_cmake_cache():
+    pioenvs = CORE.relative_pioenvs_path()
+    if os.path.isdir(pioenvs):
+        pioenvs_cmake_path = CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt")
+        if os.path.isfile(pioenvs_cmake_path):
+            _LOGGER.info("Deleting %s", pioenvs_cmake_path)
+            os.remove(pioenvs_cmake_path)
+
+
 def clean_build():
     import shutil
 

From 56a963dfe68e251acedc25ce598ca4c32bc9783a Mon Sep 17 00:00:00 2001
From: mrtntome <21003287+mrtntome@users.noreply.github.com>
Date: Wed, 2 Jul 2025 09:05:54 -0300
Subject: [PATCH 11/17] [heatpumpir] Add Support for PHS32 HeatPump (#7378)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
---
 esphome/components/heatpumpir/climate.py     | 1 +
 esphome/components/heatpumpir/heatpumpir.cpp | 1 +
 esphome/components/heatpumpir/heatpumpir.h   | 1 +
 3 files changed, 3 insertions(+)

diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py
index 9e5a2bf45c..0f9f146ae9 100644
--- a/esphome/components/heatpumpir/climate.py
+++ b/esphome/components/heatpumpir/climate.py
@@ -70,6 +70,7 @@ PROTOCOLS = {
     "airway": Protocol.PROTOCOL_AIRWAY,
     "bgh_aud": Protocol.PROTOCOL_BGH_AUD,
     "panasonic_altdke": Protocol.PROTOCOL_PANASONIC_ALTDKE,
+    "philco_phs32": Protocol.PROTOCOL_PHILCO_PHS32,
     "vaillantvai8": Protocol.PROTOCOL_VAILLANTVAI8,
     "r51m": Protocol.PROTOCOL_R51M,
 }
diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp
index d3476c6a71..f4d2ca6c1d 100644
--- a/esphome/components/heatpumpir/heatpumpir.cpp
+++ b/esphome/components/heatpumpir/heatpumpir.cpp
@@ -65,6 +65,7 @@ const std::map> PROTOCOL_CONSTRUCTOR_MAP
     {PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }},                              // NOLINT
     {PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }},                                // NOLINT
     {PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }},           // NOLINT
+    {PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }},                   // NOLINT
     {PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }},                      // NOLINT
     {PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }},                                  // NOLINT
 };
diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h
index b740d27af7..3e14c11861 100644
--- a/esphome/components/heatpumpir/heatpumpir.h
+++ b/esphome/components/heatpumpir/heatpumpir.h
@@ -65,6 +65,7 @@ enum Protocol {
   PROTOCOL_AIRWAY,
   PROTOCOL_BGH_AUD,
   PROTOCOL_PANASONIC_ALTDKE,
+  PROTOCOL_PHILCO_PHS32,
   PROTOCOL_VAILLANTVAI8,
   PROTOCOL_R51M,
 };

From 4cdc804c178088ee221943accca1398f2c3e1923 Mon Sep 17 00:00:00 2001
From: rwrozelle 
Date: Wed, 2 Jul 2025 08:16:28 -0400
Subject: [PATCH 12/17] OpenThread - add Device Type (#9272)

Co-authored-by: mc 
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
---
 esphome/components/openthread/__init__.py          | 11 ++++++++++-
 esphome/components/openthread/const.py             |  1 +
 tests/components/openthread/test.esp32-c6-idf.yaml |  1 +
 3 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py
index 65138e28c7..25e3153d1b 100644
--- a/esphome/components/openthread/__init__.py
+++ b/esphome/components/openthread/__init__.py
@@ -11,6 +11,7 @@ from esphome.const import CONF_CHANNEL, CONF_ENABLE_IPV6, CONF_ID
 import esphome.final_validate as fv
 
 from .const import (
+    CONF_DEVICE_TYPE,
     CONF_EXT_PAN_ID,
     CONF_FORCE_DATASET,
     CONF_MDNS_ID,
@@ -32,6 +33,11 @@ AUTO_LOAD = ["network"]
 CONFLICTS_WITH = ["wifi"]
 DEPENDENCIES = ["esp32"]
 
+CONF_DEVICE_TYPES = [
+    "FTD",
+    "MTD",
+]
+
 
 def set_sdkconfig_options(config):
     # and expose options for using SPI/UART RCPs
@@ -82,7 +88,7 @@ def set_sdkconfig_options(config):
     add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5)
 
     # TODO: Add suport for sleepy end devices
-    add_idf_sdkconfig_option("CONFIG_OPENTHREAD_FTD", True)  # Full Thread Device
+    add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True)
 
 
 openthread_ns = cg.esphome_ns.namespace("openthread")
@@ -107,6 +113,9 @@ CONFIG_SCHEMA = cv.All(
             cv.GenerateID(): cv.declare_id(OpenThreadComponent),
             cv.GenerateID(CONF_SRP_ID): cv.declare_id(OpenThreadSrpComponent),
             cv.GenerateID(CONF_MDNS_ID): cv.use_id(MDNSComponent),
+            cv.Optional(CONF_DEVICE_TYPE, default="FTD"): cv.one_of(
+                *CONF_DEVICE_TYPES, upper=True
+            ),
             cv.Optional(CONF_FORCE_DATASET): cv.boolean,
             cv.Optional(CONF_TLV): cv.string_strict,
         }
diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py
index 7837e69eea..7a6ffb2df4 100644
--- a/esphome/components/openthread/const.py
+++ b/esphome/components/openthread/const.py
@@ -1,3 +1,4 @@
+CONF_DEVICE_TYPE = "device_type"
 CONF_EXT_PAN_ID = "ext_pan_id"
 CONF_FORCE_DATASET = "force_dataset"
 CONF_MDNS_ID = "mdns_id"
diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml
index f53b323bec..bbcf48efa5 100644
--- a/tests/components/openthread/test.esp32-c6-idf.yaml
+++ b/tests/components/openthread/test.esp32-c6-idf.yaml
@@ -2,6 +2,7 @@ network:
   enable_ipv6: true
 
 openthread:
+  device_type: FTD
   channel: 13
   network_name: OpenThread-8f28
   network_key: 0xdfd34f0f05cad978ec4e32b0413038ff

From 289aedcfe21c54352ab8da8858d74d5828620287 Mon Sep 17 00:00:00 2001
From: Colm 
Date: Wed, 2 Jul 2025 05:23:37 -0700
Subject: [PATCH 13/17] Don't compile `state_to_string()` unless debugging.
 (#7473)

---
 esphome/components/rtttl/rtttl.cpp | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp
index 2c4a0f917f..65a3af1bbc 100644
--- a/esphome/components/rtttl/rtttl.cpp
+++ b/esphome/components/rtttl/rtttl.cpp
@@ -371,6 +371,7 @@ void Rtttl::finish_() {
   ESP_LOGD(TAG, "Playback finished");
 }
 
+#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
 static const LogString *state_to_string(State state) {
   switch (state) {
     case STATE_STOPPED:
@@ -387,6 +388,7 @@ static const LogString *state_to_string(State state) {
       return LOG_STR("UNKNOWN");
   }
 };
+#endif
 
 void Rtttl::set_state_(State state) {
   State old_state = this->state_;

From 9b3ece4caf0bdaf0633cd91c42966af09d309cce Mon Sep 17 00:00:00 2001
From: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Date: Thu, 3 Jul 2025 01:51:25 +1200
Subject: [PATCH 14/17] [time] Add ``USE_TIME_TIMEZONE`` define (#9290)

---
 esphome/components/time/__init__.py         | 18 ++++++++++++++++--
 esphome/components/time/real_time_clock.cpp |  4 ++++
 esphome/components/time/real_time_clock.h   |  4 ++++
 esphome/core/defines.h                      |  1 +
 4 files changed, 25 insertions(+), 2 deletions(-)

diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py
index 6b3ff6f4d3..ab821d457b 100644
--- a/esphome/components/time/__init__.py
+++ b/esphome/components/time/__init__.py
@@ -268,7 +268,19 @@ def validate_tz(value: str) -> str:
 
 TIME_SCHEMA = cv.Schema(
     {
-        cv.Optional(CONF_TIMEZONE, default=detect_tz): validate_tz,
+        cv.SplitDefault(
+            CONF_TIMEZONE,
+            esp8266=detect_tz,
+            esp32=detect_tz,
+            rp2040=detect_tz,
+            bk72xx=detect_tz,
+            rtl87xx=detect_tz,
+            ln882x=detect_tz,
+            host=detect_tz,
+        ): cv.All(
+            cv.only_with_framework(["arduino", "esp-idf", "host"]),
+            validate_tz,
+        ),
         cv.Optional(CONF_ON_TIME): automation.validate_automation(
             {
                 cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CronTrigger),
@@ -293,7 +305,9 @@ TIME_SCHEMA = cv.Schema(
 
 
 async def setup_time_core_(time_var, config):
-    cg.add(time_var.set_timezone(config[CONF_TIMEZONE]))
+    if timezone := config.get(CONF_TIMEZONE):
+        cg.add(time_var.set_timezone(timezone))
+        cg.add_define("USE_TIME_TIMEZONE")
 
     for conf in config.get(CONF_ON_TIME, []):
         trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp
index 11e39e8f67..61391d2c6b 100644
--- a/esphome/components/time/real_time_clock.cpp
+++ b/esphome/components/time/real_time_clock.cpp
@@ -35,8 +35,10 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
     ret = settimeofday(&timev, nullptr);
   }
 
+#ifdef USE_TIME_TIMEZONE
   // Move timezone back to local timezone.
   this->apply_timezone_();
+#endif
 
   if (ret != 0) {
     ESP_LOGW(TAG, "setimeofday() failed with code %d", ret);
@@ -49,10 +51,12 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
   this->time_sync_callback_.call();
 }
 
+#ifdef USE_TIME_TIMEZONE
 void RealTimeClock::apply_timezone_() {
   setenv("TZ", this->timezone_.c_str(), 1);
   tzset();
 }
+#endif
 
 }  // namespace time
 }  // namespace esphome
diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h
index 401798a568..9fad148885 100644
--- a/esphome/components/time/real_time_clock.h
+++ b/esphome/components/time/real_time_clock.h
@@ -20,6 +20,7 @@ class RealTimeClock : public PollingComponent {
  public:
   explicit RealTimeClock();
 
+#ifdef USE_TIME_TIMEZONE
   /// Set the time zone.
   void set_timezone(const std::string &tz) {
     this->timezone_ = tz;
@@ -28,6 +29,7 @@ class RealTimeClock : public PollingComponent {
 
   /// Get the time zone currently in use.
   std::string get_timezone() { return this->timezone_; }
+#endif
 
   /// Get the time in the currently defined timezone.
   ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
@@ -46,8 +48,10 @@ class RealTimeClock : public PollingComponent {
   /// Report a unix epoch as current time.
   void synchronize_epoch_(uint32_t epoch);
 
+#ifdef USE_TIME_TIMEZONE
   std::string timezone_{};
   void apply_timezone_();
+#endif
 
   CallbackManager time_sync_callback_;
 };
diff --git a/esphome/core/defines.h b/esphome/core/defines.h
index cfaed6fdb7..be872689f3 100644
--- a/esphome/core/defines.h
+++ b/esphome/core/defines.h
@@ -116,6 +116,7 @@
 #define USE_OTA_PASSWORD
 #define USE_OTA_STATE_CALLBACK
 #define USE_OTA_VERSION 2
+#define USE_TIME_TIMEZONE
 #define USE_WIFI
 #define USE_WIFI_AP
 #define USE_WIREGUARD

From 60eac6ea0707e0f4dd7d1e506edf1e4c504dbe67 Mon Sep 17 00:00:00 2001
From: tomaszduda23 
Date: Wed, 2 Jul 2025 16:02:56 +0200
Subject: [PATCH 15/17] [time] fix clang-tidy (#9292)

---
 esphome/components/time/real_time_clock.h | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h
index 9fad148885..4b98a88975 100644
--- a/esphome/components/time/real_time_clock.h
+++ b/esphome/components/time/real_time_clock.h
@@ -40,7 +40,7 @@ class RealTimeClock : public PollingComponent {
   /// Get the current time as the UTC epoch since January 1st 1970.
   time_t timestamp_now() { return ::time(nullptr); }
 
-  void add_on_time_sync_callback(std::function callback) {
+  void add_on_time_sync_callback(std::function &&callback) {
     this->time_sync_callback_.add(std::move(callback));
   };
 

From 00eb56d8db27af8001ba93fbb3552778eab750a2 Mon Sep 17 00:00:00 2001
From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Date: Thu, 3 Jul 2025 00:08:10 +1000
Subject: [PATCH 16/17] [esp32_touch] Fix threshold (#9291)

Co-authored-by: Keith Burzinski 
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
 esphome/components/esp32_touch/esp32_touch.h  | 22 ++++----
 .../esp32_touch/esp32_touch_common.cpp        | 17 +++---
 .../components/esp32_touch/esp32_touch_v1.cpp |  6 +-
 .../components/esp32_touch/esp32_touch_v2.cpp | 55 +++++++++----------
 4 files changed, 50 insertions(+), 50 deletions(-)

diff --git a/esphome/components/esp32_touch/esp32_touch.h b/esphome/components/esp32_touch/esp32_touch.h
index 576c1a5649..5a91b1c750 100644
--- a/esphome/components/esp32_touch/esp32_touch.h
+++ b/esphome/components/esp32_touch/esp32_touch.h
@@ -93,7 +93,6 @@ class ESP32TouchComponent : public Component {
   uint32_t last_release_check_{0};
   uint32_t release_timeout_ms_{1500};
   uint32_t release_check_interval_ms_{50};
-  bool initial_state_published_[TOUCH_PAD_MAX] = {false};
 
   // Common configuration parameters
   uint16_t sleep_cycle_{4095};
@@ -123,13 +122,6 @@ class ESP32TouchComponent : public Component {
   };
 
  protected:
-  // Design note: last_touch_time_ does not require synchronization primitives because:
-  // 1. ESP32 guarantees atomic 32-bit aligned reads/writes
-  // 2. ISR only writes timestamps, main loop only reads
-  // 3. Timing tolerance allows for occasional stale reads (50ms check interval)
-  // 4. Queue operations provide implicit memory barriers
-  // Using atomic/critical sections would add overhead without meaningful benefit
-  uint32_t last_touch_time_[TOUCH_PAD_MAX] = {0};
   uint32_t iir_filter_{0};
 
   bool iir_filter_enabled_() const { return this->iir_filter_ > 0; }
@@ -147,9 +139,6 @@ class ESP32TouchComponent : public Component {
     uint32_t intr_mask;
   };
 
-  // Track last touch time for timeout-based release detection
-  uint32_t last_touch_time_[TOUCH_PAD_MAX] = {0};
-
  protected:
   // Filter configuration
   touch_filter_mode_t filter_mode_{TOUCH_PAD_FILTER_MAX};
@@ -255,11 +244,22 @@ class ESP32TouchBinarySensor : public binary_sensor::BinarySensor {
 
   touch_pad_t touch_pad_{TOUCH_PAD_MAX};
   uint32_t threshold_{0};
+  uint32_t benchmark_{};
 #ifdef USE_ESP32_VARIANT_ESP32
   uint32_t value_{0};
 #endif
   bool last_state_{false};
   const uint32_t wakeup_threshold_{0};
+
+  // Track last touch time for timeout-based release detection
+  // Design note: last_touch_time_ does not require synchronization primitives because:
+  // 1. ESP32 guarantees atomic 32-bit aligned reads/writes
+  // 2. ISR only writes timestamps, main loop only reads
+  // 3. Timing tolerance allows for occasional stale reads (50ms check interval)
+  // 4. Queue operations provide implicit memory barriers
+  // Using atomic/critical sections would add overhead without meaningful benefit
+  uint32_t last_touch_time_{};
+  bool initial_state_published_{};
 };
 
 }  // namespace esp32_touch
diff --git a/esphome/components/esp32_touch/esp32_touch_common.cpp b/esphome/components/esp32_touch/esp32_touch_common.cpp
index fd2cdfcbad..2d93de077e 100644
--- a/esphome/components/esp32_touch/esp32_touch_common.cpp
+++ b/esphome/components/esp32_touch/esp32_touch_common.cpp
@@ -22,16 +22,20 @@ void ESP32TouchComponent::dump_config_base_() {
                 "  Sleep cycle: %.2fms\n"
                 "  Low Voltage Reference: %s\n"
                 "  High Voltage Reference: %s\n"
-                "  Voltage Attenuation: %s",
+                "  Voltage Attenuation: %s\n"
+                "  Release Timeout: %" PRIu32 "ms\n",
                 this->meas_cycle_ / (8000000.0f / 1000.0f), this->sleep_cycle_ / (150000.0f / 1000.0f), lv_s, hv_s,
-                atten_s);
+                atten_s, this->release_timeout_ms_);
 }
 
 void ESP32TouchComponent::dump_config_sensors_() {
   for (auto *child : this->children_) {
     LOG_BINARY_SENSOR("  ", "Touch Pad", child);
-    ESP_LOGCONFIG(TAG, "    Pad: T%" PRIu32, (uint32_t) child->get_touch_pad());
-    ESP_LOGCONFIG(TAG, "    Threshold: %" PRIu32, child->get_threshold());
+    ESP_LOGCONFIG(TAG,
+                  "    Pad: T%u\n"
+                  "    Threshold: %" PRIu32 "\n"
+                  "    Benchmark: %" PRIu32,
+                  (unsigned) child->touch_pad_, child->threshold_, child->benchmark_);
   }
 }
 
@@ -112,12 +116,11 @@ bool ESP32TouchComponent::should_check_for_releases_(uint32_t now) {
 }
 
 void ESP32TouchComponent::publish_initial_state_if_needed_(ESP32TouchBinarySensor *child, uint32_t now) {
-  touch_pad_t pad = child->get_touch_pad();
-  if (!this->initial_state_published_[pad]) {
+  if (!child->initial_state_published_) {
     // Check if enough time has passed since startup
     if (now > this->release_timeout_ms_) {
       child->publish_initial_state(false);
-      this->initial_state_published_[pad] = true;
+      child->initial_state_published_ = true;
       ESP_LOGV(TAG, "Touch Pad '%s' state: OFF (initial)", child->get_name().c_str());
     }
   }
diff --git a/esphome/components/esp32_touch/esp32_touch_v1.cpp b/esphome/components/esp32_touch/esp32_touch_v1.cpp
index a6d499e9fa..6f05610ed6 100644
--- a/esphome/components/esp32_touch/esp32_touch_v1.cpp
+++ b/esphome/components/esp32_touch/esp32_touch_v1.cpp
@@ -104,7 +104,7 @@ void ESP32TouchComponent::loop() {
 
       // Track when we last saw this pad as touched
       if (new_state) {
-        this->last_touch_time_[event.pad] = now;
+        child->last_touch_time_ = now;
       }
 
       // Only publish if state changed - this filters out repeated events
@@ -127,15 +127,13 @@ void ESP32TouchComponent::loop() {
 
   size_t pads_off = 0;
   for (auto *child : this->children_) {
-    touch_pad_t pad = child->get_touch_pad();
-
     // Handle initial state publication after startup
     this->publish_initial_state_if_needed_(child, now);
 
     if (child->last_state_) {
       // Pad is currently in touched state - check for release timeout
       // Using subtraction handles 32-bit rollover correctly
-      uint32_t time_diff = now - this->last_touch_time_[pad];
+      uint32_t time_diff = now - child->last_touch_time_;
 
       // Check if we haven't seen this pad recently
       if (time_diff > this->release_timeout_ms_) {
diff --git a/esphome/components/esp32_touch/esp32_touch_v2.cpp b/esphome/components/esp32_touch/esp32_touch_v2.cpp
index ad77881724..afd2655fd7 100644
--- a/esphome/components/esp32_touch/esp32_touch_v2.cpp
+++ b/esphome/components/esp32_touch/esp32_touch_v2.cpp
@@ -14,19 +14,16 @@ static const char *const TAG = "esp32_touch";
 void ESP32TouchComponent::update_touch_state_(ESP32TouchBinarySensor *child, bool is_touched) {
   // Always update timer when touched
   if (is_touched) {
-    this->last_touch_time_[child->get_touch_pad()] = App.get_loop_component_start_time();
+    child->last_touch_time_ = App.get_loop_component_start_time();
   }
 
   if (child->last_state_ != is_touched) {
-    // Read value for logging
-    uint32_t value = this->read_touch_value(child->get_touch_pad());
-
     child->last_state_ = is_touched;
     child->publish_state(is_touched);
     if (is_touched) {
       // ESP32-S2/S3 v2: touched when value > threshold
       ESP_LOGV(TAG, "Touch Pad '%s' state: ON (value: %" PRIu32 " > threshold: %" PRIu32 ")", child->get_name().c_str(),
-               value, child->get_threshold());
+               this->read_touch_value(child->touch_pad_), child->threshold_ + child->benchmark_);
     } else {
       ESP_LOGV(TAG, "Touch Pad '%s' state: OFF", child->get_name().c_str());
     }
@@ -36,10 +33,13 @@ void ESP32TouchComponent::update_touch_state_(ESP32TouchBinarySensor *child, boo
 // Helper to read touch value and update state for a given child (used for timeout events)
 bool ESP32TouchComponent::check_and_update_touch_state_(ESP32TouchBinarySensor *child) {
   // Read current touch value
-  uint32_t value = this->read_touch_value(child->get_touch_pad());
+  uint32_t value = this->read_touch_value(child->touch_pad_);
 
-  // ESP32-S2/S3 v2: Touch is detected when value > threshold
-  bool is_touched = value > child->get_threshold();
+  // ESP32-S2/S3 v2: Touch is detected when value > threshold + benchmark
+  ESP_LOGV(TAG,
+           "Checking touch state for '%s' (T%d): value = %" PRIu32 ", threshold = %" PRIu32 ", benchmark = %" PRIu32,
+           child->get_name().c_str(), child->touch_pad_, value, child->threshold_, child->benchmark_);
+  bool is_touched = value > child->benchmark_ + child->threshold_;
 
   this->update_touch_state_(child, is_touched);
   return is_touched;
@@ -61,9 +61,9 @@ void ESP32TouchComponent::setup() {
 
   // Configure each touch pad first
   for (auto *child : this->children_) {
-    esp_err_t config_err = touch_pad_config(child->get_touch_pad());
+    esp_err_t config_err = touch_pad_config(child->touch_pad_);
     if (config_err != ESP_OK) {
-      ESP_LOGE(TAG, "Failed to configure touch pad %d: %s", child->get_touch_pad(), esp_err_to_name(config_err));
+      ESP_LOGE(TAG, "Failed to configure touch pad %d: %s", child->touch_pad_, esp_err_to_name(config_err));
     }
   }
 
@@ -100,8 +100,8 @@ void ESP32TouchComponent::setup() {
 
   // Configure measurement parameters
   touch_pad_set_voltage(this->high_voltage_reference_, this->low_voltage_reference_, this->voltage_attenuation_);
-  // ESP32-S2/S3 always use the older API
-  touch_pad_set_meas_time(this->sleep_cycle_, this->meas_cycle_);
+  touch_pad_set_charge_discharge_times(this->meas_cycle_);
+  touch_pad_set_measurement_interval(this->sleep_cycle_);
 
   // Configure timeout if needed
   touch_pad_timeout_set(true, TOUCH_PAD_THRESHOLD_MAX);
@@ -118,8 +118,8 @@ void ESP32TouchComponent::setup() {
 
   // Set thresholds for each pad BEFORE starting FSM
   for (auto *child : this->children_) {
-    if (child->get_threshold() != 0) {
-      touch_pad_set_thresh(child->get_touch_pad(), child->get_threshold());
+    if (child->threshold_ != 0) {
+      touch_pad_set_thresh(child->touch_pad_, child->threshold_);
     }
   }
 
@@ -277,6 +277,7 @@ void ESP32TouchComponent::loop() {
   // Process any queued touch events from interrupts
   TouchPadEventV2 event;
   while (xQueueReceive(this->touch_queue_, &event, 0) == pdTRUE) {
+    ESP_LOGD(TAG, "Event received, mask = 0x%" PRIx32 ", pad = %d", event.intr_mask, event.pad);
     // Handle timeout events
     if (event.intr_mask & TOUCH_PAD_INTR_MASK_TIMEOUT) {
       // Resume measurement after timeout
@@ -289,18 +290,16 @@ void ESP32TouchComponent::loop() {
 
     // Find the child for the pad that triggered the interrupt
     for (auto *child : this->children_) {
-      if (child->get_touch_pad() != event.pad) {
-        continue;
+      if (child->touch_pad_ == event.pad) {
+        if (event.intr_mask & TOUCH_PAD_INTR_MASK_TIMEOUT) {
+          // For timeout events, we need to read the value to determine state
+          this->check_and_update_touch_state_(child);
+        } else if (event.intr_mask & TOUCH_PAD_INTR_MASK_ACTIVE) {
+          // We only get ACTIVE interrupts now, releases are detected by timeout
+          this->update_touch_state_(child, true);  // Always touched for ACTIVE interrupts
+        }
+        break;
       }
-
-      if (event.intr_mask & TOUCH_PAD_INTR_MASK_TIMEOUT) {
-        // For timeout events, we need to read the value to determine state
-        this->check_and_update_touch_state_(child);
-      } else if (event.intr_mask & TOUCH_PAD_INTR_MASK_ACTIVE) {
-        // We only get ACTIVE interrupts now, releases are detected by timeout
-        this->update_touch_state_(child, true);  // Always touched for ACTIVE interrupts
-      }
-      break;
     }
   }
 
@@ -311,15 +310,15 @@ void ESP32TouchComponent::loop() {
 
   size_t pads_off = 0;
   for (auto *child : this->children_) {
-    touch_pad_t pad = child->get_touch_pad();
-
+    if (child->benchmark_ == 0)
+      touch_pad_read_benchmark(child->touch_pad_, &child->benchmark_);
     // Handle initial state publication after startup
     this->publish_initial_state_if_needed_(child, now);
 
     if (child->last_state_) {
       // Pad is currently in touched state - check for release timeout
       // Using subtraction handles 32-bit rollover correctly
-      uint32_t time_diff = now - this->last_touch_time_[pad];
+      uint32_t time_diff = now - child->last_touch_time_;
 
       // Check if we haven't seen this pad recently
       if (time_diff > this->release_timeout_ms_) {

From 86fd70284154516556516621c549d431f3bb77ce Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" 
Date: Wed, 2 Jul 2025 13:56:41 -0500
Subject: [PATCH 17/17] Save flash and RAM by conditionally compiling unused
 API password code

---
 esphome/components/api/__init__.py        | 4 +++-
 esphome/components/api/api_connection.cpp | 9 ++++++++-
 esphome/components/api/api_server.cpp     | 4 ++++
 esphome/components/api/api_server.h       | 6 +++++-
 4 files changed, 20 insertions(+), 3 deletions(-)

diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py
index b02a875d72..2f1be28293 100644
--- a/esphome/components/api/__init__.py
+++ b/esphome/components/api/__init__.py
@@ -132,7 +132,9 @@ async def to_code(config):
     await cg.register_component(var, config)
 
     cg.add(var.set_port(config[CONF_PORT]))
-    cg.add(var.set_password(config[CONF_PASSWORD]))
+    if config[CONF_PASSWORD]:
+        cg.add_define("USE_API_PASSWORD")
+        cg.add(var.set_password(config[CONF_PASSWORD]))
     cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
     cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY]))
 
diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp
index e83d508c50..49ad9706bc 100644
--- a/esphome/components/api/api_connection.cpp
+++ b/esphome/components/api/api_connection.cpp
@@ -1503,7 +1503,10 @@ HelloResponse APIConnection::hello(const HelloRequest &msg) {
   return resp;
 }
 ConnectResponse APIConnection::connect(const ConnectRequest &msg) {
-  bool correct = this->parent_->check_password(msg.password);
+  bool correct = true;
+#ifdef USE_API_PASSWORD
+  correct = this->parent_->check_password(msg.password);
+#endif
 
   ConnectResponse resp;
   // bool invalid_password = 1;
@@ -1524,7 +1527,11 @@ ConnectResponse APIConnection::connect(const ConnectRequest &msg) {
 }
 DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) {
   DeviceInfoResponse resp{};
+#ifdef USE_API_PASSWORD
   resp.uses_password = this->parent_->uses_password();
+#else
+  resp.uses_password = false;
+#endif
   resp.name = App.get_name();
   resp.friendly_name = App.get_friendly_name();
   resp.suggested_area = App.get_area();
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index ebe80604dc..0fd9c1a228 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -218,6 +218,7 @@ void APIServer::dump_config() {
 #endif
 }
 
+#ifdef USE_API_PASSWORD
 bool APIServer::uses_password() const { return !this->password_.empty(); }
 
 bool APIServer::check_password(const std::string &password) const {
@@ -248,6 +249,7 @@ bool APIServer::check_password(const std::string &password) const {
 
   return result == 0;
 }
+#endif
 
 void APIServer::handle_disconnect(APIConnection *conn) {}
 
@@ -431,7 +433,9 @@ float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI;
 
 void APIServer::set_port(uint16_t port) { this->port_ = port; }
 
+#ifdef USE_API_PASSWORD
 void APIServer::set_password(const std::string &password) { this->password_ = password; }
+#endif
 
 void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; }
 
diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h
index 5a9b0677bc..9dc2b4b7d6 100644
--- a/esphome/components/api/api_server.h
+++ b/esphome/components/api/api_server.h
@@ -35,10 +35,12 @@ class APIServer : public Component, public Controller {
   void dump_config() override;
   void on_shutdown() override;
   bool teardown() override;
+#ifdef USE_API_PASSWORD
   bool check_password(const std::string &password) const;
   bool uses_password() const;
-  void set_port(uint16_t port);
   void set_password(const std::string &password);
+#endif
+  void set_port(uint16_t port);
   void set_reboot_timeout(uint32_t reboot_timeout);
   void set_batch_delay(uint16_t batch_delay);
   uint16_t get_batch_delay() const { return batch_delay_; }
@@ -179,7 +181,9 @@ class APIServer : public Component, public Controller {
 
   // Vectors and strings (12 bytes each on 32-bit)
   std::vector> clients_;
+#ifdef USE_API_PASSWORD
   std::string password_;
+#endif
   std::vector shared_write_buffer_;  // Shared proto write buffer for all connections
   std::vector state_subs_;
 #ifdef USE_API_YAML_SERVICES