From b77c1d0af8d3344fc9fa6bbdb88bb9b342d1d3ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 10:33:49 -0500 Subject: [PATCH 01/93] Add OTA support to ESP-IDF webserver --- esphome/components/web_server/__init__.py | 9 +- .../web_server_base/web_server_base.cpp | 77 +++++- .../web_server_base/web_server_base.h | 4 + .../web_server_idf/multipart_parser.cpp | 226 ++++++++++++++++++ .../web_server_idf/multipart_parser.h | 67 ++++++ .../web_server_idf/web_server_idf.cpp | 93 ++++++- 6 files changed, 463 insertions(+), 13 deletions(-) create mode 100644 esphome/components/web_server_idf/multipart_parser.cpp create mode 100644 esphome/components/web_server_idf/multipart_parser.h diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index d846a3418b..069275a6f3 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -71,12 +71,6 @@ def validate_local(config): return config -def validate_ota(config): - if CORE.using_esp_idf and config[CONF_OTA]: - raise cv.Invalid("Enabling 'ota' is not supported for IDF framework yet") - return config - - def validate_sorting_groups(config): if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -178,7 +172,7 @@ CONFIG_SCHEMA = cv.All( CONF_OTA, esp8266=True, esp32_arduino=True, - esp32_idf=False, + esp32_idf=True, bk72xx=True, rtl87xx=True, ): cv.boolean, @@ -190,7 +184,6 @@ CONFIG_SCHEMA = cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), default_url, validate_local, - validate_ota, validate_sorting_groups, ) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 2835585387..6f768d0d21 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,6 +14,10 @@ #endif #endif +#ifdef USE_ESP_IDF +#include "esphome/components/ota/ota_backend.h" +#endif + namespace esphome { namespace web_server_base { @@ -93,6 +97,67 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } } #endif + +#ifdef USE_ESP_IDF + // ESP-IDF implementation + if (index == 0) { + ESP_LOGI(TAG, "OTA Update Start: %s", filename.c_str()); + this->ota_read_length_ = 0; + this->ota_started_ = false; + + // Create OTA backend + this->ota_backend_ = ota::make_ota_backend(); + + // Begin OTA with unknown size + auto result = this->ota_backend_->begin(0); + if (result != ota::OTA_RESPONSE_OK) { + ESP_LOGE(TAG, "OTA begin failed: %d", result); + this->ota_backend_.reset(); + return; + } + this->ota_started_ = true; + } else if (!this->ota_started_ || !this->ota_backend_) { + // Begin failed or was aborted + return; + } + + // Write data + if (len > 0) { + auto result = this->ota_backend_->write(data, len); + if (result != ota::OTA_RESPONSE_OK) { + ESP_LOGE(TAG, "OTA write failed: %d", result); + this->ota_backend_->abort(); + this->ota_backend_.reset(); + this->ota_started_ = false; + return; + } + + this->ota_read_length_ += len; + + 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; + } + } + + if (final) { + auto result = this->ota_backend_->end(); + if (result == ota::OTA_RESPONSE_OK) { + ESP_LOGI(TAG, "OTA update successful!"); + this->parent_->set_timeout(100, []() { App.safe_reboot(); }); + } else { + ESP_LOGE(TAG, "OTA end failed: %d", result); + } + this->ota_backend_.reset(); + this->ota_started_ = false; + } +#endif } void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #ifdef USE_ARDUINO @@ -108,10 +173,20 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { response->addHeader("Connection", "close"); request->send(response); #endif +#ifdef USE_ESP_IDF + AsyncWebServerResponse *response; + if (this->ota_started_ && this->ota_backend_) { + response = request->beginResponse(200, "text/plain", "Update Successful!"); + } else { + response = request->beginResponse(200, "text/plain", "Update Failed!"); + } + response->addHeader("Connection", "close"); + request->send(response); +#endif } void WebServerBase::add_ota_handler() { -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP_IDF) this->add_handler(new OTARequestHandler(this)); // NOLINT #endif } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 641006cb99..33aba6247a 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -142,6 +142,10 @@ class OTARequestHandler : public AsyncWebHandler { uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; WebServerBase *parent_; +#ifdef USE_ESP_IDF + std::unique_ptr ota_backend_; + bool ota_started_{false}; +#endif }; } // namespace web_server_base diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp new file mode 100644 index 0000000000..89417733d6 --- /dev/null +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -0,0 +1,226 @@ +#ifdef USE_ESP_IDF +#include "multipart_parser.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace web_server_idf { + +static const char *const TAG = "multipart_parser"; + +bool MultipartParser::parse(const uint8_t *data, size_t len) { + // Append new data to buffer + buffer_.insert(buffer_.end(), data, data + len); + + while (state_ != DONE && state_ != ERROR && !buffer_.empty()) { + switch (state_) { + case BOUNDARY_SEARCH: + if (!find_boundary()) { + return false; + } + state_ = HEADERS; + break; + + case HEADERS: + if (!parse_headers()) { + return false; + } + state_ = CONTENT; + content_start_ = 0; // Content starts at current buffer position + break; + + case CONTENT: + if (!extract_content()) { + return false; + } + break; + + default: + break; + } + } + + return part_ready_; +} + +bool MultipartParser::get_current_part(Part &part) const { + if (!part_ready_ || content_length_ == 0) { + return false; + } + + part.name = current_name_; + part.filename = current_filename_; + part.content_type = current_content_type_; + part.data = buffer_.data() + content_start_; + part.length = content_length_; + + return true; +} + +void MultipartParser::consume_part() { + if (!part_ready_) { + return; + } + + // Remove consumed data from buffer + if (content_start_ + content_length_ < buffer_.size()) { + buffer_.erase(buffer_.begin(), buffer_.begin() + content_start_ + content_length_); + } else { + buffer_.clear(); + } + + // Reset for next part + part_ready_ = false; + content_start_ = 0; + content_length_ = 0; + current_name_.clear(); + current_filename_.clear(); + current_content_type_.clear(); + + // Look for next boundary + state_ = BOUNDARY_SEARCH; +} + +void MultipartParser::reset() { + buffer_.clear(); + state_ = BOUNDARY_SEARCH; + part_ready_ = false; + content_start_ = 0; + content_length_ = 0; + current_name_.clear(); + current_filename_.clear(); + current_content_type_.clear(); +} + +bool MultipartParser::find_boundary() { + // Look for boundary in buffer + size_t boundary_pos = find_pattern(reinterpret_cast(boundary_.c_str()), boundary_.length()); + + if (boundary_pos == std::string::npos) { + // Keep some data for next iteration to handle split boundaries + if (buffer_.size() > boundary_.length() + 4) { + buffer_.erase(buffer_.begin(), buffer_.end() - boundary_.length() - 4); + } + return false; + } + + // Remove everything up to and including the boundary + buffer_.erase(buffer_.begin(), buffer_.begin() + boundary_pos + boundary_.length()); + + // Skip CRLF after boundary + if (buffer_.size() >= 2 && buffer_[0] == '\r' && buffer_[1] == '\n') { + buffer_.erase(buffer_.begin(), buffer_.begin() + 2); + } + + // Check if this is the end boundary + if (buffer_.size() >= 2 && buffer_[0] == '-' && buffer_[1] == '-') { + state_ = DONE; + return false; + } + + return true; +} + +bool MultipartParser::parse_headers() { + while (true) { + std::string line = read_line(); + if (line.empty()) { + // Check if we have enough data for a line + auto crlf_pos = find_pattern(reinterpret_cast("\r\n"), 2); + if (crlf_pos == std::string::npos) { + return false; // Need more data + } + // Empty line means headers are done + buffer_.erase(buffer_.begin(), buffer_.begin() + 2); + return true; + } + + // Parse Content-Disposition header + if (line.find("Content-Disposition:") == 0) { + // Extract name + size_t name_pos = line.find("name=\""); + if (name_pos != std::string::npos) { + name_pos += 6; + size_t name_end = line.find("\"", name_pos); + if (name_end != std::string::npos) { + current_name_ = line.substr(name_pos, name_end - name_pos); + } + } + + // Extract filename if present + size_t filename_pos = line.find("filename=\""); + if (filename_pos != std::string::npos) { + filename_pos += 10; + size_t filename_end = line.find("\"", filename_pos); + if (filename_end != std::string::npos) { + current_filename_ = line.substr(filename_pos, filename_end - filename_pos); + } + } + } + // Parse Content-Type header + else if (line.find("Content-Type:") == 0) { + current_content_type_ = line.substr(14); + // Trim whitespace + size_t start = current_content_type_.find_first_not_of(" \t"); + if (start != std::string::npos) { + current_content_type_ = current_content_type_.substr(start); + } + } + } +} + +bool MultipartParser::extract_content() { + // Look for next boundary + std::string search_boundary = "\r\n" + boundary_; + size_t boundary_pos = + find_pattern(reinterpret_cast(search_boundary.c_str()), search_boundary.length()); + + if (boundary_pos != std::string::npos) { + // Found complete part + content_length_ = boundary_pos - content_start_; + part_ready_ = true; + return true; + } + + // No boundary found yet, but we might have partial content + // Keep enough bytes to ensure we don't split a boundary + size_t safe_length = buffer_.size(); + if (safe_length > search_boundary.length() + 4) { + safe_length -= search_boundary.length() + 4; + if (safe_length > content_start_) { + content_length_ = safe_length - content_start_; + // We have partial content but not complete yet + return false; + } + } + + return false; +} + +std::string MultipartParser::read_line() { + auto crlf_pos = find_pattern(reinterpret_cast("\r\n"), 2); + if (crlf_pos == std::string::npos) { + return ""; + } + + std::string line(buffer_.begin(), buffer_.begin() + crlf_pos); + buffer_.erase(buffer_.begin(), buffer_.begin() + crlf_pos + 2); + return line; +} + +size_t MultipartParser::find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start) const { + if (buffer_.size() < pattern_len + start) { + return std::string::npos; + } + + for (size_t i = start; i <= buffer_.size() - pattern_len; ++i) { + if (memcmp(buffer_.data() + i, pattern, pattern_len) == 0) { + return i; + } + } + + return std::string::npos; +} + +} // namespace web_server_idf +} // namespace esphome +#endif \ No newline at end of file diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h new file mode 100644 index 0000000000..6d3f3f6575 --- /dev/null +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -0,0 +1,67 @@ +#pragma once +#ifdef USE_ESP_IDF + +#include +#include +#include + +namespace esphome { +namespace web_server_idf { + +// Multipart form data parser for ESP-IDF +class MultipartParser { + public: + enum State { BOUNDARY_SEARCH, HEADERS, CONTENT, DONE, ERROR }; + + struct Part { + std::string name; + std::string filename; + std::string content_type; + const uint8_t *data; + size_t length; + }; + + explicit MultipartParser(const std::string &boundary) : boundary_("--" + boundary), state_(BOUNDARY_SEARCH) {} + + // Process incoming data chunk + // Returns true if a complete part is available + bool parse(const uint8_t *data, size_t len); + + // Get the current part if available + bool get_current_part(Part &part) const; + + // Consume the current part and move to next + void consume_part(); + + State get_state() const { return state_; } + bool is_done() const { return state_ == DONE; } + bool has_error() const { return state_ == ERROR; } + + // Reset parser for reuse + void reset(); + + private: + bool find_boundary(); + bool parse_headers(); + bool extract_content(); + + std::string read_line(); + size_t find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start = 0) const; + + std::string boundary_; + std::string end_boundary_; + State state_; + std::vector buffer_; + + // Current part info + std::string current_name_; + std::string current_filename_; + std::string current_content_type_; + size_t content_start_{0}; + size_t content_length_{0}; + bool part_ready_{false}; +}; + +} // namespace web_server_idf +} // namespace esphome +#endif \ No newline at end of file diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 90fdf720cd..2e1cf185db 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -8,6 +8,7 @@ #include "esp_tls_crypto.h" #include "utils.h" +#include "multipart_parser.h" #include "web_server_idf.h" @@ -72,10 +73,24 @@ void AsyncWebServer::begin() { esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { ESP_LOGVV(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); auto content_type = request_get_header(r, "Content-Type"); - if (content_type.has_value() && *content_type != "application/x-www-form-urlencoded") { - ESP_LOGW(TAG, "Only application/x-www-form-urlencoded supported for POST request"); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); + + // Check if this is a multipart form data request (for OTA updates) + bool is_multipart = false; + std::string boundary; + if (content_type.has_value()) { + std::string ct = content_type.value(); + if (ct.find("multipart/form-data") != std::string::npos) { + is_multipart = true; + // Extract boundary + size_t boundary_pos = ct.find("boundary="); + if (boundary_pos != std::string::npos) { + boundary = ct.substr(boundary_pos + 9); + } + } else if (ct != "application/x-www-form-urlencoded") { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); + // fallback to get handler to support backward compatibility + return AsyncWebServer::request_handler(r); + } } if (!request_has_header(r, "Content-Length")) { @@ -84,6 +99,76 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_OK; } + // Handle multipart form data + if (is_multipart && !boundary.empty()) { + // Create request object + AsyncWebServerRequest req(r); + auto *server = static_cast(r->user_ctx); + + // Find handler that can handle this request + AsyncWebHandler *found_handler = nullptr; + for (auto *handler : server->handlers_) { + if (handler->canHandle(&req)) { + found_handler = handler; + break; + } + } + + if (!found_handler) { + httpd_resp_send_err(r, HTTPD_404_NOT_FOUND, nullptr); + return ESP_OK; + } + + // Handle multipart upload + MultipartParser parser(boundary); + static constexpr size_t CHUNK_SIZE = 1024; + uint8_t *chunk_buf = new uint8_t[CHUNK_SIZE]; + size_t total_len = r->content_len; + size_t remaining = total_len; + bool first_part = true; + + while (remaining > 0) { + size_t to_read = std::min(remaining, CHUNK_SIZE); + int recv_len = httpd_req_recv(r, reinterpret_cast(chunk_buf), to_read); + + if (recv_len <= 0) { + delete[] chunk_buf; + if (recv_len == HTTPD_SOCK_ERR_TIMEOUT) { + httpd_resp_send_err(r, HTTPD_408_REQ_TIMEOUT, nullptr); + return ESP_ERR_TIMEOUT; + } + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + // Parse multipart data + if (parser.parse(chunk_buf, recv_len)) { + MultipartParser::Part part; + if (parser.get_current_part(part) && !part.filename.empty()) { + // This is a file upload + found_handler->handleUpload(&req, part.filename, first_part ? 0 : 1, const_cast(part.data), + part.length, false); + first_part = false; + parser.consume_part(); + } + } + + remaining -= recv_len; + } + + // Final call to handler + if (!first_part) { + found_handler->handleUpload(&req, "", 2, nullptr, 0, true); + } + + delete[] chunk_buf; + + // Let handler send response + found_handler->handleRequest(&req); + return ESP_OK; + } + + // Handle regular form data if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { ESP_LOGW(TAG, "Request size is to big: %zu", r->content_len); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); From 7efbd627305df8cc6078607bfb4e98ecbc544d1b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 10:34:49 -0500 Subject: [PATCH 02/93] Add OTA support to ESP-IDF webserver --- esphome/components/web_server/__init__.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 069275a6f3..731efe623b 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -168,14 +168,7 @@ CONFIG_SCHEMA = cv.All( web_server_base.WebServerBase ), cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean, - cv.SplitDefault( - CONF_OTA, - esp8266=True, - esp32_arduino=True, - esp32_idf=True, - bk72xx=True, - rtl87xx=True, - ): cv.boolean, + cv.Optional(CONF_OTA, default=True): 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), From c366d555e9d68228d77d6f1350e453016bd2c193 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 10:38:53 -0500 Subject: [PATCH 03/93] Add OTA support to ESP-IDF webserver --- esphome/components/web_server/__init__.py | 2 ++ .../components/web_server_base/web_server_base.cpp | 8 ++++---- esphome/components/web_server_base/web_server_base.h | 2 +- .../components/web_server_idf/multipart_parser.cpp | 4 +++- esphome/components/web_server_idf/multipart_parser.h | 4 +++- esphome/components/web_server_idf/web_server_idf.cpp | 12 ++++++++++++ 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 731efe623b..733b53b039 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -261,6 +261,8 @@ async def to_code(config): 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]: + cg.add_define("USE_WEBSERVER_OTA") 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_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 6f768d0d21..e6d04b16ef 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,7 +14,7 @@ #endif #endif -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include "esphome/components/ota/ota_backend.h" #endif @@ -98,7 +98,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } #endif -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) // ESP-IDF implementation if (index == 0) { ESP_LOGI(TAG, "OTA Update Start: %s", filename.c_str()); @@ -173,7 +173,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { response->addHeader("Connection", "close"); request->send(response); #endif -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) AsyncWebServerResponse *response; if (this->ota_started_ && this->ota_backend_) { response = request->beginResponse(200, "text/plain", "Update Successful!"); @@ -186,7 +186,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { } void WebServerBase::add_ota_handler() { -#if defined(USE_ARDUINO) || defined(USE_ESP_IDF) +#if defined(USE_ARDUINO) || (defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA)) this->add_handler(new OTARequestHandler(this)); // NOLINT #endif } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 33aba6247a..75876109b5 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -142,7 +142,7 @@ class OTARequestHandler : public AsyncWebHandler { uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; WebServerBase *parent_; -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) std::unique_ptr ota_backend_; bool ota_started_{false}; #endif diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 89417733d6..d13840dac4 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -1,4 +1,5 @@ #ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA #include "multipart_parser.h" #include "esphome/core/log.h" @@ -223,4 +224,5 @@ size_t MultipartParser::find_pattern(const uint8_t *pattern, size_t pattern_len, } // namespace web_server_idf } // namespace esphome -#endif \ No newline at end of file +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index 6d3f3f6575..41ab7d2837 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -1,5 +1,6 @@ #pragma once #ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA #include #include @@ -64,4 +65,5 @@ class MultipartParser { } // namespace web_server_idf } // namespace esphome -#endif \ No newline at end of file +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2e1cf185db..1aad9b49d2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -8,7 +8,9 @@ #include "esp_tls_crypto.h" #include "utils.h" +#ifdef USE_WEBSERVER_OTA #include "multipart_parser.h" +#endif #include "web_server_idf.h" @@ -74,6 +76,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { ESP_LOGVV(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); auto content_type = request_get_header(r, "Content-Type"); +#ifdef USE_WEBSERVER_OTA // Check if this is a multipart form data request (for OTA updates) bool is_multipart = false; std::string boundary; @@ -92,6 +95,13 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return AsyncWebServer::request_handler(r); } } +#else + if (content_type.has_value() && content_type.value() != "application/x-www-form-urlencoded") { + ESP_LOGW(TAG, "Only application/x-www-form-urlencoded supported for POST request"); + // fallback to get handler to support backward compatibility + return AsyncWebServer::request_handler(r); + } +#endif if (!request_has_header(r, "Content-Length")) { ESP_LOGW(TAG, "Content length is requred for post: %s", r->uri); @@ -99,6 +109,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_OK; } +#ifdef USE_WEBSERVER_OTA // Handle multipart form data if (is_multipart && !boundary.empty()) { // Create request object @@ -167,6 +178,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { found_handler->handleRequest(&req); return ESP_OK; } +#endif // USE_WEBSERVER_OTA // Handle regular form data if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { From 9047b02c92b1340391e843b6cf54574c035a49cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 10:53:29 -0500 Subject: [PATCH 04/93] fixes --- .../web_server_idf/multipart_parser.cpp | 40 +++++++++++-------- .../web_server_idf/multipart_parser.h | 7 +++- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index d13840dac4..15492875b8 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -10,28 +10,34 @@ static const char *const TAG = "multipart_parser"; bool MultipartParser::parse(const uint8_t *data, size_t len) { // Append new data to buffer - buffer_.insert(buffer_.end(), data, data + len); + if (data && len > 0) { + buffer_.insert(buffer_.end(), data, data + len); + } + + bool made_progress = true; + while (made_progress && state_ != DONE && state_ != ERROR && !buffer_.empty()) { + made_progress = false; - while (state_ != DONE && state_ != ERROR && !buffer_.empty()) { switch (state_) { case BOUNDARY_SEARCH: - if (!find_boundary()) { - return false; + if (find_boundary()) { + state_ = HEADERS; + made_progress = true; } - state_ = HEADERS; break; case HEADERS: - if (!parse_headers()) { - return false; + if (parse_headers()) { + state_ = CONTENT; + content_start_ = 0; // Content starts at current buffer position + made_progress = true; } - state_ = CONTENT; - content_start_ = 0; // Content starts at current buffer position break; case CONTENT: - if (!extract_content()) { - return false; + if (extract_content()) { + // Content is ready, return to caller + return true; } break; @@ -51,7 +57,7 @@ bool MultipartParser::get_current_part(Part &part) const { part.name = current_name_; part.filename = current_filename_; part.content_type = current_content_type_; - part.data = buffer_.data() + content_start_; + part.data = buffer_.data(); part.length = content_length_; return true; @@ -63,8 +69,8 @@ void MultipartParser::consume_part() { } // Remove consumed data from buffer - if (content_start_ + content_length_ < buffer_.size()) { - buffer_.erase(buffer_.begin(), buffer_.begin() + content_start_ + content_length_); + if (content_length_ < buffer_.size()) { + buffer_.erase(buffer_.begin(), buffer_.begin() + content_length_); } else { buffer_.clear(); } @@ -177,7 +183,7 @@ bool MultipartParser::extract_content() { if (boundary_pos != std::string::npos) { // Found complete part - content_length_ = boundary_pos - content_start_; + content_length_ = boundary_pos; part_ready_ = true; return true; } @@ -187,8 +193,8 @@ bool MultipartParser::extract_content() { size_t safe_length = buffer_.size(); if (safe_length > search_boundary.length() + 4) { safe_length -= search_boundary.length() + 4; - if (safe_length > content_start_) { - content_length_ = safe_length - content_start_; + if (safe_length > 0) { + content_length_ = safe_length; // We have partial content but not complete yet return false; } diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index 41ab7d2837..5d2d940e79 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -22,7 +22,12 @@ class MultipartParser { size_t length; }; - explicit MultipartParser(const std::string &boundary) : boundary_("--" + boundary), state_(BOUNDARY_SEARCH) {} + explicit MultipartParser(const std::string &boundary) + : boundary_("--" + boundary), + state_(BOUNDARY_SEARCH), + content_start_(0), + content_length_(0), + part_ready_(false) {} // Process incoming data chunk // Returns true if a complete part is available From 614a2f66a353789e89b3451c9c62496b962fbda4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 10:57:00 -0500 Subject: [PATCH 05/93] fixes --- .../web_server_idf/multipart_parser.cpp | 54 ++++---- .../web_server_idf/multipart_parser_utils.h | 128 ++++++++++++++++++ 2 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 esphome/components/web_server_idf/multipart_parser_utils.h diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 15492875b8..8dcad5cd1b 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP_IDF #ifdef USE_WEBSERVER_OTA #include "multipart_parser.h" +#include "multipart_parser_utils.h" #include "esphome/core/log.h" namespace esphome { @@ -141,35 +142,38 @@ bool MultipartParser::parse_headers() { return true; } - // Parse Content-Disposition header - if (line.find("Content-Disposition:") == 0) { - // Extract name - size_t name_pos = line.find("name=\""); - if (name_pos != std::string::npos) { - name_pos += 6; - size_t name_end = line.find("\"", name_pos); - if (name_end != std::string::npos) { - current_name_ = line.substr(name_pos, name_end - name_pos); - } + // Parse Content-Disposition header (case-insensitive) + if (str_startswith_case_insensitive(line, "content-disposition:")) { + // Extract name parameter + std::string name = extract_header_param(line, "name"); + if (!name.empty()) { + current_name_ = name; } - // Extract filename if present - size_t filename_pos = line.find("filename=\""); - if (filename_pos != std::string::npos) { - filename_pos += 10; - size_t filename_end = line.find("\"", filename_pos); - if (filename_end != std::string::npos) { - current_filename_ = line.substr(filename_pos, filename_end - filename_pos); - } + // Extract filename parameter if present + std::string filename = extract_header_param(line, "filename"); + if (!filename.empty()) { + current_filename_ = filename; } } - // Parse Content-Type header - else if (line.find("Content-Type:") == 0) { - current_content_type_ = line.substr(14); - // Trim whitespace - size_t start = current_content_type_.find_first_not_of(" \t"); - if (start != std::string::npos) { - current_content_type_ = current_content_type_.substr(start); + // Parse Content-Type header (case-insensitive) + else if (str_startswith_case_insensitive(line, "content-type:")) { + // Find the colon and skip it + size_t colon_pos = line.find(':'); + if (colon_pos != std::string::npos) { + current_content_type_ = line.substr(colon_pos + 1); + // Trim leading whitespace + size_t start = current_content_type_.find_first_not_of(" \t"); + if (start != std::string::npos) { + current_content_type_ = current_content_type_.substr(start); + } else { + current_content_type_.clear(); + } + // Trim trailing whitespace + size_t end = current_content_type_.find_last_not_of(" \t\r\n"); + if (end != std::string::npos) { + current_content_type_ = current_content_type_.substr(0, end + 1); + } } } } diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h new file mode 100644 index 0000000000..43b7ced03d --- /dev/null +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -0,0 +1,128 @@ +#pragma once +#ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA + +#include +#include + +namespace esphome { +namespace web_server_idf { + +// Case-insensitive string comparison +inline bool str_equals_case_insensitive(const std::string &a, const std::string &b) { + if (a.length() != b.length()) { + return false; + } + for (size_t i = 0; i < a.length(); i++) { + if (tolower(a[i]) != tolower(b[i])) { + return false; + } + } + return true; +} + +// Case-insensitive string prefix check +inline bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { + if (str.length() < prefix.length()) { + return false; + } + for (size_t i = 0; i < prefix.length(); i++) { + if (tolower(str[i]) != tolower(prefix[i])) { + return false; + } + } + return true; +} + +// Find a substring case-insensitively +inline size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos = 0) { + if (needle.empty() || pos >= haystack.length()) { + return std::string::npos; + } + + for (size_t i = pos; i <= haystack.length() - needle.length(); i++) { + bool match = true; + for (size_t j = 0; j < needle.length(); j++) { + if (tolower(haystack[i + j]) != tolower(needle[j])) { + match = false; + break; + } + } + if (match) { + return i; + } + } + + return std::string::npos; +} + +// Extract a parameter value from a header line +// Handles both quoted and unquoted values +inline std::string extract_header_param(const std::string &header, const std::string ¶m) { + size_t search_pos = 0; + + while (search_pos < header.length()) { + // Look for param name + size_t pos = str_find_case_insensitive(header, param, search_pos); + if (pos == std::string::npos) { + return ""; + } + + // Check if this is a word boundary (not part of another parameter) + if (pos > 0 && header[pos - 1] != ' ' && header[pos - 1] != ';' && header[pos - 1] != '\t') { + search_pos = pos + 1; + continue; + } + + // Move past param name + pos += param.length(); + + // Skip whitespace and find '=' + while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { + pos++; + } + + if (pos >= header.length() || header[pos] != '=') { + search_pos = pos; + continue; + } + + pos++; // Skip '=' + + // Skip whitespace after '=' + while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { + pos++; + } + + if (pos >= header.length()) { + return ""; + } + + // Check if value is quoted + if (header[pos] == '"') { + pos++; + size_t end = header.find('"', pos); + if (end != std::string::npos) { + return header.substr(pos, end - pos); + } + // Malformed - no closing quote + return ""; + } + + // Unquoted value - find the end (semicolon, comma, or end of string) + size_t end = pos; + while (end < header.length() && header[end] != ';' && header[end] != ',' && header[end] != ' ' && + header[end] != '\t') { + end++; + } + + return header.substr(pos, end - pos); + } + + return ""; +} + +} // namespace web_server_idf +} // namespace esphome +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file From 2b7bc1cd9f2ad65e7d59705e64c838c5b18cd59e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:03:37 -0500 Subject: [PATCH 06/93] fixes --- .../web_server_idf/multipart_parser.h | 2 +- .../web_server_idf/multipart_parser_utils.h | 127 +++++-- .../web_server_idf/test_multipart_parser.cpp | 319 ++++++++++++++++++ .../web_server_idf/web_server_idf.cpp | 25 +- 4 files changed, 438 insertions(+), 35 deletions(-) create mode 100644 esphome/components/web_server_idf/test_multipart_parser.cpp diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index 5d2d940e79..466bfd6dd4 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -12,7 +12,7 @@ namespace web_server_idf { // Multipart form data parser for ESP-IDF class MultipartParser { public: - enum State { BOUNDARY_SEARCH, HEADERS, CONTENT, DONE, ERROR }; + enum State : uint8_t { BOUNDARY_SEARCH, HEADERS, CONTENT, DONE, ERROR }; struct Part { std::string name; diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index 43b7ced03d..a644a392ad 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -4,21 +4,30 @@ #include #include +#include namespace esphome { namespace web_server_idf { +// Helper function for case-insensitive character comparison +inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } + +// Helper function for case-insensitive string region comparison +inline bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { + for (size_t i = 0; i < n; i++) { + if (!char_equals_ci(s1[i], s2[i])) { + return false; + } + } + return true; +} + // Case-insensitive string comparison inline bool str_equals_case_insensitive(const std::string &a, const std::string &b) { if (a.length() != b.length()) { return false; } - for (size_t i = 0; i < a.length(); i++) { - if (tolower(a[i]) != tolower(b[i])) { - return false; - } - } - return true; + return str_ncmp_ci(a.c_str(), b.c_str(), a.length()); } // Case-insensitive string prefix check @@ -26,12 +35,7 @@ inline bool str_startswith_case_insensitive(const std::string &str, const std::s if (str.length() < prefix.length()) { return false; } - for (size_t i = 0; i < prefix.length(); i++) { - if (tolower(str[i]) != tolower(prefix[i])) { - return false; - } - } - return true; + return str_ncmp_ci(str.c_str(), prefix.c_str(), prefix.length()); } // Find a substring case-insensitively @@ -40,15 +44,11 @@ inline size_t str_find_case_insensitive(const std::string &haystack, const std:: return std::string::npos; } - for (size_t i = pos; i <= haystack.length() - needle.length(); i++) { - bool match = true; - for (size_t j = 0; j < needle.length(); j++) { - if (tolower(haystack[i + j]) != tolower(needle[j])) { - match = false; - break; - } - } - if (match) { + const size_t needle_len = needle.length(); + const size_t max_pos = haystack.length() - needle_len; + + for (size_t i = pos; i <= max_pos; i++) { + if (str_ncmp_ci(haystack.c_str() + i, needle.c_str(), needle_len)) { return i; } } @@ -122,6 +122,91 @@ inline std::string extract_header_param(const std::string &header, const std::st return ""; } +// Case-insensitive string search (like strstr but case-insensitive) +inline const char *stristr(const char *haystack, const char *needle) { + if (!haystack || !needle) { + return nullptr; + } + + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return haystack; + } + + for (const char *p = haystack; *p; p++) { + if (str_ncmp_ci(p, needle, needle_len)) { + return p; + } + } + + return nullptr; +} + +// Parse boundary from Content-Type header +// Returns true if boundary found, false otherwise +// boundary_start and boundary_len will point to the boundary value +inline bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len) { + if (!content_type) { + return false; + } + + // Check for multipart/form-data (case-insensitive) + if (!stristr(content_type, "multipart/form-data")) { + return false; + } + + // Look for boundary parameter + const char *b = stristr(content_type, "boundary="); + if (!b) { + return false; + } + + const char *start = b + 9; // Skip "boundary=" + + // Skip whitespace + while (*start == ' ' || *start == '\t') { + start++; + } + + if (!*start) { + return false; + } + + // Find end of boundary + const char *end = start; + if (*end == '"') { + // Quoted boundary + start++; + end++; + while (*end && *end != '"') { + end++; + } + *boundary_len = end - start; + } else { + // Unquoted boundary + while (*end && *end != ' ' && *end != ';' && *end != '\r' && *end != '\n' && *end != '\t') { + end++; + } + *boundary_len = end - start; + } + + if (*boundary_len == 0) { + return false; + } + + *boundary_start = start; + return true; +} + +// Check if content type is form-urlencoded (case-insensitive) +inline bool is_form_urlencoded(const char *content_type) { + if (!content_type) { + return false; + } + + return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; +} + } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA diff --git a/esphome/components/web_server_idf/test_multipart_parser.cpp b/esphome/components/web_server_idf/test_multipart_parser.cpp new file mode 100644 index 0000000000..3579cdb982 --- /dev/null +++ b/esphome/components/web_server_idf/test_multipart_parser.cpp @@ -0,0 +1,319 @@ +#ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA + +#include +#include +#include +#include +#include + +#include "multipart_parser.h" + +namespace esphome { +namespace web_server_idf { +namespace test { + +void print_test_result(const std::string &test_name, bool passed) { + std::cout << test_name << ": " << (passed ? "PASSED" : "FAILED") << std::endl; +} + +bool test_simple_multipart() { + std::string boundary = "----WebKitFormBoundary1234567890"; + std::string data = "------WebKitFormBoundary1234567890\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"test.bin\"\r\n" + "Content-Type: application/octet-stream\r\n" + "\r\n" + "Hello World!\r\n" + "------WebKitFormBoundary1234567890--\r\n"; + + MultipartParser parser(boundary); + bool result = parser.parse(reinterpret_cast(data.c_str()), data.length()); + + if (!result) { + return false; + } + + MultipartParser::Part part; + if (!parser.get_current_part(part)) { + return false; + } + + return part.filename == "test.bin" && part.name == "file" && part.length == 12 && + memcmp(part.data, "Hello World!", 12) == 0; +} + +bool test_chunked_parsing() { + std::string boundary = "----WebKitFormBoundary1234567890"; + std::string data = "------WebKitFormBoundary1234567890\r\n" + "Content-Disposition: form-data; name=\"firmware\"; filename=\"app.bin\"\r\n" + "Content-Type: application/octet-stream\r\n" + "\r\n" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n" + "------WebKitFormBoundary1234567890--\r\n"; + + MultipartParser parser(boundary); + + // Parse in small chunks + size_t chunk_size = 10; + bool found_part = false; + + for (size_t i = 0; i < data.length(); i += chunk_size) { + size_t len = std::min(chunk_size, data.length() - i); + bool has_part = parser.parse(reinterpret_cast(data.c_str() + i), len); + + if (has_part && !found_part) { + found_part = true; + MultipartParser::Part part; + if (!parser.get_current_part(part)) { + return false; + } + + return part.filename == "app.bin" && part.name == "firmware" && part.length == 26 && + memcmp(part.data, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 26) == 0; + } + } + + return found_part; +} + +bool test_multiple_parts() { + std::string boundary = "----WebKitFormBoundary1234567890"; + std::string data = "------WebKitFormBoundary1234567890\r\n" + "Content-Disposition: form-data; name=\"field1\"\r\n" + "\r\n" + "value1\r\n" + "------WebKitFormBoundary1234567890\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"test.bin\"\r\n" + "Content-Type: application/octet-stream\r\n" + "\r\n" + "Binary content here\r\n" + "------WebKitFormBoundary1234567890--\r\n"; + + MultipartParser parser(boundary); + std::vector parts; + + // Parse all at once + size_t offset = 0; + while (offset < data.length()) { + size_t chunk_size = data.length() - offset; + bool has_part = parser.parse(reinterpret_cast(data.c_str() + offset), chunk_size); + + if (has_part) { + MultipartParser::Part part; + if (parser.get_current_part(part)) { + parts.push_back(part); + parser.consume_part(); + } + } + + offset += chunk_size; + + if (parser.is_done()) { + break; + } + } + + if (parts.size() != 2) { + return false; + } + + // Check first part (form field) + if (parts[0].name != "field1" || !parts[0].filename.empty() || parts[0].length != 6 || + memcmp(parts[0].data, "value1", 6) != 0) { + return false; + } + + // Check second part (file) + if (parts[1].name != "file" || parts[1].filename != "test.bin" || parts[1].length != 19 || + memcmp(parts[1].data, "Binary content here", 19) != 0) { + return false; + } + + return true; +} + +bool test_boundary_edge_cases() { + // Test when boundary is split across chunks + std::string boundary = "----WebKitFormBoundary1234567890"; + std::string data = "------WebKitFormBoundary1234567890\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"test.bin\"\r\n" + "\r\n" + "Content before boundary\r\n" + "------WebKitFormBoundary1234567890--\r\n"; + + MultipartParser parser(boundary); + + // Parse with boundary split across chunks + std::vector chunks = { + std::string(data.c_str(), 50), // Part of headers + std::string(data.c_str() + 50, 60), // Rest of headers + start of content + std::string(data.c_str() + 110, 20), // Middle of content + std::string(data.c_str() + 130, data.length() - 130) // End with boundary + }; + + bool found_part = false; + for (const auto &chunk : chunks) { + bool has_part = parser.parse(reinterpret_cast(chunk.c_str()), chunk.length()); + + if (has_part && !found_part) { + found_part = true; + MultipartParser::Part part; + if (!parser.get_current_part(part)) { + return false; + } + + return part.filename == "test.bin" && part.length == 23 && memcmp(part.data, "Content before boundary", 23) == 0; + } + } + + return found_part; +} + +bool test_empty_filename() { + std::string boundary = "xyz123"; + std::string data = "--xyz123\r\n" + "Content-Disposition: form-data; name=\"field\"\r\n" + "\r\n" + "Just a regular field\r\n" + "--xyz123--\r\n"; + + MultipartParser parser(boundary); + bool result = parser.parse(reinterpret_cast(data.c_str()), data.length()); + + if (!result) { + return false; + } + + MultipartParser::Part part; + if (!parser.get_current_part(part)) { + return false; + } + + return part.name == "field" && part.filename.empty() && part.length == 20 && + memcmp(part.data, "Just a regular field", 20) == 0; +} + +bool test_content_type_header() { + std::string boundary = "boundary123"; + std::string data = "--boundary123\r\n" + "Content-Disposition: form-data; name=\"upload\"; filename=\"data.json\"\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "{\"key\": \"value\"}\r\n" + "--boundary123--\r\n"; + + MultipartParser parser(boundary); + bool result = parser.parse(reinterpret_cast(data.c_str()), data.length()); + + if (!result) { + return false; + } + + MultipartParser::Part part; + if (!parser.get_current_part(part)) { + return false; + } + + return part.name == "upload" && part.filename == "data.json" && part.content_type == "application/json" && + part.length == 16 && memcmp(part.data, "{\"key\": \"value\"}", 16) == 0; +} + +bool test_large_content() { + std::string boundary = "----WebKitFormBoundary1234567890"; + + // Generate large content + std::string large_content; + for (int i = 0; i < 1000; i++) { + large_content += "0123456789"; + } + + std::string data = "------WebKitFormBoundary1234567890\r\n" + "Content-Disposition: form-data; name=\"firmware\"; filename=\"large.bin\"\r\n" + "\r\n" + + large_content + + "\r\n" + "------WebKitFormBoundary1234567890--\r\n"; + + MultipartParser parser(boundary); + + // Parse in realistic chunks + size_t chunk_size = 256; + bool found_complete = false; + size_t total_content_parsed = 0; + + for (size_t i = 0; i < data.length(); i += chunk_size) { + size_t len = std::min(chunk_size, data.length() - i); + bool has_part = parser.parse(reinterpret_cast(data.c_str() + i), len); + + if (has_part) { + MultipartParser::Part part; + if (parser.get_current_part(part)) { + // For large content, we might get it in pieces + if (part.length == large_content.length()) { + found_complete = true; + return part.filename == "large.bin" && part.length == 10000 && + memcmp(part.data, large_content.c_str(), part.length) == 0; + } + } + } + } + + return found_complete; +} + +bool test_reset_parser() { + std::string boundary = "test"; + std::string data1 = "--test\r\n" + "Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n" + "\r\n" + "AAA\r\n" + "--test--\r\n"; + + std::string data2 = "--test\r\n" + "Content-Disposition: form-data; name=\"file2\"; filename=\"b.txt\"\r\n" + "\r\n" + "BBB\r\n" + "--test--\r\n"; + + MultipartParser parser(boundary); + + // Parse first data + parser.parse(reinterpret_cast(data1.c_str()), data1.length()); + MultipartParser::Part part1; + parser.get_current_part(part1); + + // Reset and parse second data + parser.reset(); + parser.parse(reinterpret_cast(data2.c_str()), data2.length()); + MultipartParser::Part part2; + parser.get_current_part(part2); + + return part1.filename == "a.txt" && part1.length == 3 && memcmp(part1.data, "AAA", 3) == 0 && + part2.filename == "b.txt" && part2.length == 3 && memcmp(part2.data, "BBB", 3) == 0; +} + +void run_all_tests() { + std::cout << "Running Multipart Parser Tests..." << std::endl; + + print_test_result("Simple multipart", test_simple_multipart()); + print_test_result("Chunked parsing", test_chunked_parsing()); + print_test_result("Multiple parts", test_multiple_parts()); + print_test_result("Boundary edge cases", test_boundary_edge_cases()); + print_test_result("Empty filename", test_empty_filename()); + print_test_result("Content-Type header", test_content_type_header()); + print_test_result("Large content", test_large_content()); + print_test_result("Reset parser", test_reset_parser()); +} + +} // namespace test +} // namespace web_server_idf +} // namespace esphome + +// Standalone test runner +int main() { + esphome::web_server_idf::test::run_all_tests(); + return 0; +} + +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 1aad9b49d2..93425862d2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -10,6 +10,7 @@ #include "utils.h" #ifdef USE_WEBSERVER_OTA #include "multipart_parser.h" +#include "multipart_parser_utils.h" #endif #include "web_server_idf.h" @@ -78,19 +79,16 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { #ifdef USE_WEBSERVER_OTA // Check if this is a multipart form data request (for OTA updates) + const char *boundary_start = nullptr; + size_t boundary_len = 0; bool is_multipart = false; - std::string boundary; + if (content_type.has_value()) { - std::string ct = content_type.value(); - if (ct.find("multipart/form-data") != std::string::npos) { - is_multipart = true; - // Extract boundary - size_t boundary_pos = ct.find("boundary="); - if (boundary_pos != std::string::npos) { - boundary = ct.substr(boundary_pos + 9); - } - } else if (ct != "application/x-www-form-urlencoded") { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); + const char *ct = content_type.value().c_str(); + is_multipart = parse_multipart_boundary(ct, &boundary_start, &boundary_len); + + if (!is_multipart && !is_form_urlencoded(ct)) { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct); // fallback to get handler to support backward compatibility return AsyncWebServer::request_handler(r); } @@ -111,7 +109,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { #ifdef USE_WEBSERVER_OTA // Handle multipart form data - if (is_multipart && !boundary.empty()) { + if (is_multipart && boundary_start && boundary_len > 0) { // Create request object AsyncWebServerRequest req(r); auto *server = static_cast(r->user_ctx); @@ -130,7 +128,8 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_OK; } - // Handle multipart upload + // Handle multipart upload - create boundary string only when needed + std::string boundary(boundary_start, boundary_len); MultipartParser parser(boundary); static constexpr size_t CHUNK_SIZE = 1024; uint8_t *chunk_buf = new uint8_t[CHUNK_SIZE]; From f57e26c54e5ddc1c44593619c3196e3ba61de59b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:07:25 -0500 Subject: [PATCH 07/93] fixes --- .../web_server_idf/multipart_parser.cpp | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 8dcad5cd1b..0eb7db6a8c 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -9,6 +9,12 @@ namespace web_server_idf { static const char *const TAG = "multipart_parser"; +// Constants for multipart parsing +static constexpr size_t CRLF_LENGTH = 2; +static constexpr size_t MIN_BOUNDARY_BUFFER = 4; // Extra bytes to keep for split boundary detection +static constexpr const char *CRLF_STR = "\r\n"; +static constexpr const char *DOUBLE_DASH = "--"; + bool MultipartParser::parse(const uint8_t *data, size_t len) { // Append new data to buffer if (data && len > 0) { @@ -105,8 +111,8 @@ bool MultipartParser::find_boundary() { if (boundary_pos == std::string::npos) { // Keep some data for next iteration to handle split boundaries - if (buffer_.size() > boundary_.length() + 4) { - buffer_.erase(buffer_.begin(), buffer_.end() - boundary_.length() - 4); + if (buffer_.size() > boundary_.length() + MIN_BOUNDARY_BUFFER) { + buffer_.erase(buffer_.begin(), buffer_.end() - boundary_.length() - MIN_BOUNDARY_BUFFER); } return false; } @@ -115,12 +121,12 @@ bool MultipartParser::find_boundary() { buffer_.erase(buffer_.begin(), buffer_.begin() + boundary_pos + boundary_.length()); // Skip CRLF after boundary - if (buffer_.size() >= 2 && buffer_[0] == '\r' && buffer_[1] == '\n') { - buffer_.erase(buffer_.begin(), buffer_.begin() + 2); + if (buffer_.size() >= CRLF_LENGTH && buffer_[0] == '\r' && buffer_[1] == '\n') { + buffer_.erase(buffer_.begin(), buffer_.begin() + CRLF_LENGTH); } // Check if this is the end boundary - if (buffer_.size() >= 2 && buffer_[0] == '-' && buffer_[1] == '-') { + if (buffer_.size() >= CRLF_LENGTH && buffer_[0] == '-' && buffer_[1] == '-') { state_ = DONE; return false; } @@ -133,12 +139,12 @@ bool MultipartParser::parse_headers() { std::string line = read_line(); if (line.empty()) { // Check if we have enough data for a line - auto crlf_pos = find_pattern(reinterpret_cast("\r\n"), 2); + auto crlf_pos = find_pattern(reinterpret_cast(CRLF_STR), CRLF_LENGTH); if (crlf_pos == std::string::npos) { return false; // Need more data } // Empty line means headers are done - buffer_.erase(buffer_.begin(), buffer_.begin() + 2); + buffer_.erase(buffer_.begin(), buffer_.begin() + CRLF_LENGTH); return true; } @@ -181,7 +187,7 @@ bool MultipartParser::parse_headers() { bool MultipartParser::extract_content() { // Look for next boundary - std::string search_boundary = "\r\n" + boundary_; + std::string search_boundary = CRLF_STR + boundary_; size_t boundary_pos = find_pattern(reinterpret_cast(search_boundary.c_str()), search_boundary.length()); @@ -195,8 +201,8 @@ bool MultipartParser::extract_content() { // No boundary found yet, but we might have partial content // Keep enough bytes to ensure we don't split a boundary size_t safe_length = buffer_.size(); - if (safe_length > search_boundary.length() + 4) { - safe_length -= search_boundary.length() + 4; + if (safe_length > search_boundary.length() + MIN_BOUNDARY_BUFFER) { + safe_length -= search_boundary.length() + MIN_BOUNDARY_BUFFER; if (safe_length > 0) { content_length_ = safe_length; // We have partial content but not complete yet @@ -208,13 +214,13 @@ bool MultipartParser::extract_content() { } std::string MultipartParser::read_line() { - auto crlf_pos = find_pattern(reinterpret_cast("\r\n"), 2); + auto crlf_pos = find_pattern(reinterpret_cast(CRLF_STR), CRLF_LENGTH); if (crlf_pos == std::string::npos) { return ""; } std::string line(buffer_.begin(), buffer_.begin() + crlf_pos); - buffer_.erase(buffer_.begin(), buffer_.begin() + crlf_pos + 2); + buffer_.erase(buffer_.begin(), buffer_.begin() + crlf_pos + CRLF_LENGTH); return line; } From 15a995b2e7db1d15bcbab33514ffa74b095cc7c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:07:48 -0500 Subject: [PATCH 08/93] fixes --- esphome/components/web_server_idf/multipart_parser.cpp | 1 - esphome/components/web_server_idf/multipart_parser.h | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 0eb7db6a8c..5d6cd6f1ad 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -13,7 +13,6 @@ static const char *const TAG = "multipart_parser"; static constexpr size_t CRLF_LENGTH = 2; static constexpr size_t MIN_BOUNDARY_BUFFER = 4; // Extra bytes to keep for split boundary detection static constexpr const char *CRLF_STR = "\r\n"; -static constexpr const char *DOUBLE_DASH = "--"; bool MultipartParser::parse(const uint8_t *data, size_t len) { // Append new data to buffer diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index 466bfd6dd4..c0a36f95e9 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -12,6 +12,8 @@ namespace web_server_idf { // Multipart form data parser for ESP-IDF class MultipartParser { public: + static constexpr const char *MULTIPART_BOUNDARY_PREFIX = "--"; + enum State : uint8_t { BOUNDARY_SEARCH, HEADERS, CONTENT, DONE, ERROR }; struct Part { @@ -23,7 +25,7 @@ class MultipartParser { }; explicit MultipartParser(const std::string &boundary) - : boundary_("--" + boundary), + : boundary_(MULTIPART_BOUNDARY_PREFIX + boundary), state_(BOUNDARY_SEARCH), content_start_(0), content_length_(0), From b16edb5a994b13f0db92f57db8a8d412adf52ad0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:09:05 -0500 Subject: [PATCH 09/93] fixes --- esphome/components/web_server_idf/multipart_parser.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index c0a36f95e9..878c54be05 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -10,6 +10,7 @@ namespace esphome { namespace web_server_idf { // Multipart form data parser for ESP-IDF +// Implements RFC 7578 compliant multipart/form-data parsing class MultipartParser { public: static constexpr const char *MULTIPART_BOUNDARY_PREFIX = "--"; From 04860567f7c6eae186e5c611d33e1707847af477 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:10:29 -0500 Subject: [PATCH 10/93] fixes --- .../web_server_idf/multipart_parser.cpp | 46 +++++-------------- .../web_server_idf/multipart_parser.h | 1 + .../web_server_idf/multipart_parser_utils.h | 19 ++++++++ 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 5d6cd6f1ad..e01ef458ed 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -147,43 +147,21 @@ bool MultipartParser::parse_headers() { return true; } - // Parse Content-Disposition header (case-insensitive) - if (str_startswith_case_insensitive(line, "content-disposition:")) { - // Extract name parameter - std::string name = extract_header_param(line, "name"); - if (!name.empty()) { - current_name_ = name; - } - - // Extract filename parameter if present - std::string filename = extract_header_param(line, "filename"); - if (!filename.empty()) { - current_filename_ = filename; - } - } - // Parse Content-Type header (case-insensitive) - else if (str_startswith_case_insensitive(line, "content-type:")) { - // Find the colon and skip it - size_t colon_pos = line.find(':'); - if (colon_pos != std::string::npos) { - current_content_type_ = line.substr(colon_pos + 1); - // Trim leading whitespace - size_t start = current_content_type_.find_first_not_of(" \t"); - if (start != std::string::npos) { - current_content_type_ = current_content_type_.substr(start); - } else { - current_content_type_.clear(); - } - // Trim trailing whitespace - size_t end = current_content_type_.find_last_not_of(" \t\r\n"); - if (end != std::string::npos) { - current_content_type_ = current_content_type_.substr(0, end + 1); - } - } - } + process_header_line(line); } } +void MultipartParser::process_header_line(const std::string &line) { + if (str_startswith_case_insensitive(line, "content-disposition:")) { + // Extract name and filename parameters + current_name_ = extract_header_param(line, "name"); + current_filename_ = extract_header_param(line, "filename"); + } else if (str_startswith_case_insensitive(line, "content-type:")) { + current_content_type_ = extract_header_value(line); + } + // RFC 7578: Ignore any other Content-* headers +} + bool MultipartParser::extract_content() { // Look for next boundary std::string search_boundary = CRLF_STR + boundary_; diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index 878c54be05..cc9b82dbb2 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -52,6 +52,7 @@ class MultipartParser { private: bool find_boundary(); bool parse_headers(); + void process_header_line(const std::string &line); bool extract_content(); std::string read_line(); diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index a644a392ad..d938674efb 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -207,6 +207,25 @@ inline bool is_form_urlencoded(const char *content_type) { return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; } +// Trim whitespace from both ends of a string +inline std::string str_trim(const std::string &str) { + size_t start = str.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) { + return ""; + } + size_t end = str.find_last_not_of(" \t\r\n"); + return str.substr(start, end - start + 1); +} + +// Extract header value (everything after the colon) +inline std::string extract_header_value(const std::string &header) { + size_t colon_pos = header.find(':'); + if (colon_pos == std::string::npos) { + return ""; + } + return str_trim(header.substr(colon_pos + 1)); +} + } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA From 7b8cfc768d8ccd14cbdb7a738a2712745d39744e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:11:47 -0500 Subject: [PATCH 11/93] fixes --- .../web_server_idf/multipart_parser.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index e01ef458ed..eafb6b416a 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -20,9 +20,14 @@ bool MultipartParser::parse(const uint8_t *data, size_t len) { buffer_.insert(buffer_.end(), data, data + len); } + // Limit iterations to prevent infinite loops + static constexpr size_t MAX_ITERATIONS = 10; + size_t iterations = 0; + bool made_progress = true; - while (made_progress && state_ != DONE && state_ != ERROR && !buffer_.empty()) { + while (made_progress && state_ != DONE && state_ != ERROR && !buffer_.empty() && iterations < MAX_ITERATIONS) { made_progress = false; + iterations++; switch (state_) { case BOUNDARY_SEARCH: @@ -45,13 +50,20 @@ bool MultipartParser::parse(const uint8_t *data, size_t len) { // Content is ready, return to caller return true; } - break; + // If we're waiting for more data in CONTENT state, exit the loop + return false; default: + ESP_LOGE(TAG, "Invalid parser state: %d", state_); + state_ = ERROR; break; } } + if (iterations >= MAX_ITERATIONS) { + ESP_LOGW(TAG, "Parser reached maximum iterations, possible malformed data"); + } + return part_ready_; } From b2641d29c1ba0e668baba02c92b68f2055a51322 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:12:40 -0500 Subject: [PATCH 12/93] fixes --- .../web_server_idf/multipart_parser.cpp | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index eafb6b416a..4e3cc69fd6 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -146,7 +146,11 @@ bool MultipartParser::find_boundary() { } bool MultipartParser::parse_headers() { - while (true) { + // Limit header lines to prevent DOS attacks + static constexpr size_t MAX_HEADER_LINES = 50; + size_t header_count = 0; + + while (header_count < MAX_HEADER_LINES) { std::string line = read_line(); if (line.empty()) { // Check if we have enough data for a line @@ -160,7 +164,12 @@ bool MultipartParser::parse_headers() { } process_header_line(line); + header_count++; } + + ESP_LOGW(TAG, "Too many headers in multipart data"); + state_ = ERROR; + return false; } void MultipartParser::process_header_line(const std::string &line) { @@ -203,8 +212,22 @@ bool MultipartParser::extract_content() { } std::string MultipartParser::read_line() { + // Limit line length to prevent excessive memory usage + static constexpr size_t MAX_LINE_LENGTH = 4096; + auto crlf_pos = find_pattern(reinterpret_cast(CRLF_STR), CRLF_LENGTH); if (crlf_pos == std::string::npos) { + // If we have too much data without CRLF, it's likely malformed + if (buffer_.size() > MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Header line too long, truncating"); + state_ = ERROR; + } + return ""; + } + + if (crlf_pos > MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Header line exceeds maximum length"); + state_ = ERROR; return ""; } From b049f0b480538767a004a4e0d48423e8588fbe6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:13:25 -0500 Subject: [PATCH 13/93] fixes --- .../web_server_idf/multipart_parser.cpp | 2 +- .../web_server_idf/multipart_parser.h | 2 +- .../web_server_idf/multipart_parser_utils.h | 2 +- .../web_server_idf/test_multipart_parser.cpp | 319 ------------------ 4 files changed, 3 insertions(+), 322 deletions(-) delete mode 100644 esphome/components/web_server_idf/test_multipart_parser.cpp diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 4e3cc69fd6..888da455a4 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -253,4 +253,4 @@ size_t MultipartParser::find_pattern(const uint8_t *pattern, size_t pattern_len, } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file +#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index cc9b82dbb2..562916499a 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -75,4 +75,4 @@ class MultipartParser { } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file +#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index d938674efb..c8ee197b17 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -229,4 +229,4 @@ inline std::string extract_header_value(const std::string &header) { } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file +#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/test_multipart_parser.cpp b/esphome/components/web_server_idf/test_multipart_parser.cpp deleted file mode 100644 index 3579cdb982..0000000000 --- a/esphome/components/web_server_idf/test_multipart_parser.cpp +++ /dev/null @@ -1,319 +0,0 @@ -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA - -#include -#include -#include -#include -#include - -#include "multipart_parser.h" - -namespace esphome { -namespace web_server_idf { -namespace test { - -void print_test_result(const std::string &test_name, bool passed) { - std::cout << test_name << ": " << (passed ? "PASSED" : "FAILED") << std::endl; -} - -bool test_simple_multipart() { - std::string boundary = "----WebKitFormBoundary1234567890"; - std::string data = "------WebKitFormBoundary1234567890\r\n" - "Content-Disposition: form-data; name=\"file\"; filename=\"test.bin\"\r\n" - "Content-Type: application/octet-stream\r\n" - "\r\n" - "Hello World!\r\n" - "------WebKitFormBoundary1234567890--\r\n"; - - MultipartParser parser(boundary); - bool result = parser.parse(reinterpret_cast(data.c_str()), data.length()); - - if (!result) { - return false; - } - - MultipartParser::Part part; - if (!parser.get_current_part(part)) { - return false; - } - - return part.filename == "test.bin" && part.name == "file" && part.length == 12 && - memcmp(part.data, "Hello World!", 12) == 0; -} - -bool test_chunked_parsing() { - std::string boundary = "----WebKitFormBoundary1234567890"; - std::string data = "------WebKitFormBoundary1234567890\r\n" - "Content-Disposition: form-data; name=\"firmware\"; filename=\"app.bin\"\r\n" - "Content-Type: application/octet-stream\r\n" - "\r\n" - "ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n" - "------WebKitFormBoundary1234567890--\r\n"; - - MultipartParser parser(boundary); - - // Parse in small chunks - size_t chunk_size = 10; - bool found_part = false; - - for (size_t i = 0; i < data.length(); i += chunk_size) { - size_t len = std::min(chunk_size, data.length() - i); - bool has_part = parser.parse(reinterpret_cast(data.c_str() + i), len); - - if (has_part && !found_part) { - found_part = true; - MultipartParser::Part part; - if (!parser.get_current_part(part)) { - return false; - } - - return part.filename == "app.bin" && part.name == "firmware" && part.length == 26 && - memcmp(part.data, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 26) == 0; - } - } - - return found_part; -} - -bool test_multiple_parts() { - std::string boundary = "----WebKitFormBoundary1234567890"; - std::string data = "------WebKitFormBoundary1234567890\r\n" - "Content-Disposition: form-data; name=\"field1\"\r\n" - "\r\n" - "value1\r\n" - "------WebKitFormBoundary1234567890\r\n" - "Content-Disposition: form-data; name=\"file\"; filename=\"test.bin\"\r\n" - "Content-Type: application/octet-stream\r\n" - "\r\n" - "Binary content here\r\n" - "------WebKitFormBoundary1234567890--\r\n"; - - MultipartParser parser(boundary); - std::vector parts; - - // Parse all at once - size_t offset = 0; - while (offset < data.length()) { - size_t chunk_size = data.length() - offset; - bool has_part = parser.parse(reinterpret_cast(data.c_str() + offset), chunk_size); - - if (has_part) { - MultipartParser::Part part; - if (parser.get_current_part(part)) { - parts.push_back(part); - parser.consume_part(); - } - } - - offset += chunk_size; - - if (parser.is_done()) { - break; - } - } - - if (parts.size() != 2) { - return false; - } - - // Check first part (form field) - if (parts[0].name != "field1" || !parts[0].filename.empty() || parts[0].length != 6 || - memcmp(parts[0].data, "value1", 6) != 0) { - return false; - } - - // Check second part (file) - if (parts[1].name != "file" || parts[1].filename != "test.bin" || parts[1].length != 19 || - memcmp(parts[1].data, "Binary content here", 19) != 0) { - return false; - } - - return true; -} - -bool test_boundary_edge_cases() { - // Test when boundary is split across chunks - std::string boundary = "----WebKitFormBoundary1234567890"; - std::string data = "------WebKitFormBoundary1234567890\r\n" - "Content-Disposition: form-data; name=\"file\"; filename=\"test.bin\"\r\n" - "\r\n" - "Content before boundary\r\n" - "------WebKitFormBoundary1234567890--\r\n"; - - MultipartParser parser(boundary); - - // Parse with boundary split across chunks - std::vector chunks = { - std::string(data.c_str(), 50), // Part of headers - std::string(data.c_str() + 50, 60), // Rest of headers + start of content - std::string(data.c_str() + 110, 20), // Middle of content - std::string(data.c_str() + 130, data.length() - 130) // End with boundary - }; - - bool found_part = false; - for (const auto &chunk : chunks) { - bool has_part = parser.parse(reinterpret_cast(chunk.c_str()), chunk.length()); - - if (has_part && !found_part) { - found_part = true; - MultipartParser::Part part; - if (!parser.get_current_part(part)) { - return false; - } - - return part.filename == "test.bin" && part.length == 23 && memcmp(part.data, "Content before boundary", 23) == 0; - } - } - - return found_part; -} - -bool test_empty_filename() { - std::string boundary = "xyz123"; - std::string data = "--xyz123\r\n" - "Content-Disposition: form-data; name=\"field\"\r\n" - "\r\n" - "Just a regular field\r\n" - "--xyz123--\r\n"; - - MultipartParser parser(boundary); - bool result = parser.parse(reinterpret_cast(data.c_str()), data.length()); - - if (!result) { - return false; - } - - MultipartParser::Part part; - if (!parser.get_current_part(part)) { - return false; - } - - return part.name == "field" && part.filename.empty() && part.length == 20 && - memcmp(part.data, "Just a regular field", 20) == 0; -} - -bool test_content_type_header() { - std::string boundary = "boundary123"; - std::string data = "--boundary123\r\n" - "Content-Disposition: form-data; name=\"upload\"; filename=\"data.json\"\r\n" - "Content-Type: application/json\r\n" - "\r\n" - "{\"key\": \"value\"}\r\n" - "--boundary123--\r\n"; - - MultipartParser parser(boundary); - bool result = parser.parse(reinterpret_cast(data.c_str()), data.length()); - - if (!result) { - return false; - } - - MultipartParser::Part part; - if (!parser.get_current_part(part)) { - return false; - } - - return part.name == "upload" && part.filename == "data.json" && part.content_type == "application/json" && - part.length == 16 && memcmp(part.data, "{\"key\": \"value\"}", 16) == 0; -} - -bool test_large_content() { - std::string boundary = "----WebKitFormBoundary1234567890"; - - // Generate large content - std::string large_content; - for (int i = 0; i < 1000; i++) { - large_content += "0123456789"; - } - - std::string data = "------WebKitFormBoundary1234567890\r\n" - "Content-Disposition: form-data; name=\"firmware\"; filename=\"large.bin\"\r\n" - "\r\n" + - large_content + - "\r\n" - "------WebKitFormBoundary1234567890--\r\n"; - - MultipartParser parser(boundary); - - // Parse in realistic chunks - size_t chunk_size = 256; - bool found_complete = false; - size_t total_content_parsed = 0; - - for (size_t i = 0; i < data.length(); i += chunk_size) { - size_t len = std::min(chunk_size, data.length() - i); - bool has_part = parser.parse(reinterpret_cast(data.c_str() + i), len); - - if (has_part) { - MultipartParser::Part part; - if (parser.get_current_part(part)) { - // For large content, we might get it in pieces - if (part.length == large_content.length()) { - found_complete = true; - return part.filename == "large.bin" && part.length == 10000 && - memcmp(part.data, large_content.c_str(), part.length) == 0; - } - } - } - } - - return found_complete; -} - -bool test_reset_parser() { - std::string boundary = "test"; - std::string data1 = "--test\r\n" - "Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n" - "\r\n" - "AAA\r\n" - "--test--\r\n"; - - std::string data2 = "--test\r\n" - "Content-Disposition: form-data; name=\"file2\"; filename=\"b.txt\"\r\n" - "\r\n" - "BBB\r\n" - "--test--\r\n"; - - MultipartParser parser(boundary); - - // Parse first data - parser.parse(reinterpret_cast(data1.c_str()), data1.length()); - MultipartParser::Part part1; - parser.get_current_part(part1); - - // Reset and parse second data - parser.reset(); - parser.parse(reinterpret_cast(data2.c_str()), data2.length()); - MultipartParser::Part part2; - parser.get_current_part(part2); - - return part1.filename == "a.txt" && part1.length == 3 && memcmp(part1.data, "AAA", 3) == 0 && - part2.filename == "b.txt" && part2.length == 3 && memcmp(part2.data, "BBB", 3) == 0; -} - -void run_all_tests() { - std::cout << "Running Multipart Parser Tests..." << std::endl; - - print_test_result("Simple multipart", test_simple_multipart()); - print_test_result("Chunked parsing", test_chunked_parsing()); - print_test_result("Multiple parts", test_multiple_parts()); - print_test_result("Boundary edge cases", test_boundary_edge_cases()); - print_test_result("Empty filename", test_empty_filename()); - print_test_result("Content-Type header", test_content_type_header()); - print_test_result("Large content", test_large_content()); - print_test_result("Reset parser", test_reset_parser()); -} - -} // namespace test -} // namespace web_server_idf -} // namespace esphome - -// Standalone test runner -int main() { - esphome::web_server_idf::test::run_all_tests(); - return 0; -} - -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file From f61a40efb8c24d846c6657f1ebbe5c1f95bfac2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 11:16:00 -0500 Subject: [PATCH 14/93] fixes --- esphome/components/web_server_idf/multipart_parser.cpp | 3 --- esphome/components/web_server_idf/multipart_parser.h | 3 --- .../components/web_server_idf/multipart_parser_utils.h | 8 -------- 3 files changed, 14 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp index 888da455a4..6576951a4f 100644 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ b/esphome/components/web_server_idf/multipart_parser.cpp @@ -40,7 +40,6 @@ bool MultipartParser::parse(const uint8_t *data, size_t len) { case HEADERS: if (parse_headers()) { state_ = CONTENT; - content_start_ = 0; // Content starts at current buffer position made_progress = true; } break; @@ -95,7 +94,6 @@ void MultipartParser::consume_part() { // Reset for next part part_ready_ = false; - content_start_ = 0; content_length_ = 0; current_name_.clear(); current_filename_.clear(); @@ -109,7 +107,6 @@ void MultipartParser::reset() { buffer_.clear(); state_ = BOUNDARY_SEARCH; part_ready_ = false; - content_start_ = 0; content_length_ = 0; current_name_.clear(); current_filename_.clear(); diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h index 562916499a..480b35a5a1 100644 --- a/esphome/components/web_server_idf/multipart_parser.h +++ b/esphome/components/web_server_idf/multipart_parser.h @@ -28,7 +28,6 @@ class MultipartParser { explicit MultipartParser(const std::string &boundary) : boundary_(MULTIPART_BOUNDARY_PREFIX + boundary), state_(BOUNDARY_SEARCH), - content_start_(0), content_length_(0), part_ready_(false) {} @@ -59,7 +58,6 @@ class MultipartParser { size_t find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start = 0) const; std::string boundary_; - std::string end_boundary_; State state_; std::vector buffer_; @@ -67,7 +65,6 @@ class MultipartParser { std::string current_name_; std::string current_filename_; std::string current_content_type_; - size_t content_start_{0}; size_t content_length_{0}; bool part_ready_{false}; }; diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index c8ee197b17..616f388c54 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -22,14 +22,6 @@ inline bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { return true; } -// Case-insensitive string comparison -inline bool str_equals_case_insensitive(const std::string &a, const std::string &b) { - if (a.length() != b.length()) { - return false; - } - return str_ncmp_ci(a.c_str(), b.c_str(), a.length()); -} - // Case-insensitive string prefix check inline bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { if (str.length() < prefix.length()) { From 6596f864be04ce27831a2fdd6e45af96c7c4e2af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 14:35:38 -0500 Subject: [PATCH 15/93] merg3 --- esphome/components/web_server_idf/__init__.py | 10 +- .../web_server_idf/multipart_parser.cpp | 253 ------------------ .../web_server_idf/multipart_parser.h | 75 ------ .../web_server_idf/multipart_reader.cpp | 193 +++++++++++++ .../web_server_idf/multipart_reader.h | 65 +++++ .../web_server_idf/web_server_idf.cpp | 87 ++++-- 6 files changed, 327 insertions(+), 356 deletions(-) delete mode 100644 esphome/components/web_server_idf/multipart_parser.cpp delete mode 100644 esphome/components/web_server_idf/multipart_parser.h create mode 100644 esphome/components/web_server_idf/multipart_reader.cpp create mode 100644 esphome/components/web_server_idf/multipart_reader.h diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 506e1c5c13..03f8e60715 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,5 +1,7 @@ -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option import esphome.config_validation as cv +from esphome.const import CONF_OTA +from esphome.core import CORE CODEOWNERS = ["@dentra"] @@ -12,3 +14,9 @@ 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 + web_server_config = CORE.config.get("web_server", {}) + if web_server_config.get(CONF_OTA, True): # OTA is enabled by default + # Add multipart parser component for OTA support + add_idf_component(name="zorxx/multipart-parser", ref="1.0.1") diff --git a/esphome/components/web_server_idf/multipart_parser.cpp b/esphome/components/web_server_idf/multipart_parser.cpp deleted file mode 100644 index 6576951a4f..0000000000 --- a/esphome/components/web_server_idf/multipart_parser.cpp +++ /dev/null @@ -1,253 +0,0 @@ -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA -#include "multipart_parser.h" -#include "multipart_parser_utils.h" -#include "esphome/core/log.h" - -namespace esphome { -namespace web_server_idf { - -static const char *const TAG = "multipart_parser"; - -// Constants for multipart parsing -static constexpr size_t CRLF_LENGTH = 2; -static constexpr size_t MIN_BOUNDARY_BUFFER = 4; // Extra bytes to keep for split boundary detection -static constexpr const char *CRLF_STR = "\r\n"; - -bool MultipartParser::parse(const uint8_t *data, size_t len) { - // Append new data to buffer - if (data && len > 0) { - buffer_.insert(buffer_.end(), data, data + len); - } - - // Limit iterations to prevent infinite loops - static constexpr size_t MAX_ITERATIONS = 10; - size_t iterations = 0; - - bool made_progress = true; - while (made_progress && state_ != DONE && state_ != ERROR && !buffer_.empty() && iterations < MAX_ITERATIONS) { - made_progress = false; - iterations++; - - switch (state_) { - case BOUNDARY_SEARCH: - if (find_boundary()) { - state_ = HEADERS; - made_progress = true; - } - break; - - case HEADERS: - if (parse_headers()) { - state_ = CONTENT; - made_progress = true; - } - break; - - case CONTENT: - if (extract_content()) { - // Content is ready, return to caller - return true; - } - // If we're waiting for more data in CONTENT state, exit the loop - return false; - - default: - ESP_LOGE(TAG, "Invalid parser state: %d", state_); - state_ = ERROR; - break; - } - } - - if (iterations >= MAX_ITERATIONS) { - ESP_LOGW(TAG, "Parser reached maximum iterations, possible malformed data"); - } - - return part_ready_; -} - -bool MultipartParser::get_current_part(Part &part) const { - if (!part_ready_ || content_length_ == 0) { - return false; - } - - part.name = current_name_; - part.filename = current_filename_; - part.content_type = current_content_type_; - part.data = buffer_.data(); - part.length = content_length_; - - return true; -} - -void MultipartParser::consume_part() { - if (!part_ready_) { - return; - } - - // Remove consumed data from buffer - if (content_length_ < buffer_.size()) { - buffer_.erase(buffer_.begin(), buffer_.begin() + content_length_); - } else { - buffer_.clear(); - } - - // Reset for next part - part_ready_ = false; - content_length_ = 0; - current_name_.clear(); - current_filename_.clear(); - current_content_type_.clear(); - - // Look for next boundary - state_ = BOUNDARY_SEARCH; -} - -void MultipartParser::reset() { - buffer_.clear(); - state_ = BOUNDARY_SEARCH; - part_ready_ = false; - content_length_ = 0; - current_name_.clear(); - current_filename_.clear(); - current_content_type_.clear(); -} - -bool MultipartParser::find_boundary() { - // Look for boundary in buffer - size_t boundary_pos = find_pattern(reinterpret_cast(boundary_.c_str()), boundary_.length()); - - if (boundary_pos == std::string::npos) { - // Keep some data for next iteration to handle split boundaries - if (buffer_.size() > boundary_.length() + MIN_BOUNDARY_BUFFER) { - buffer_.erase(buffer_.begin(), buffer_.end() - boundary_.length() - MIN_BOUNDARY_BUFFER); - } - return false; - } - - // Remove everything up to and including the boundary - buffer_.erase(buffer_.begin(), buffer_.begin() + boundary_pos + boundary_.length()); - - // Skip CRLF after boundary - if (buffer_.size() >= CRLF_LENGTH && buffer_[0] == '\r' && buffer_[1] == '\n') { - buffer_.erase(buffer_.begin(), buffer_.begin() + CRLF_LENGTH); - } - - // Check if this is the end boundary - if (buffer_.size() >= CRLF_LENGTH && buffer_[0] == '-' && buffer_[1] == '-') { - state_ = DONE; - return false; - } - - return true; -} - -bool MultipartParser::parse_headers() { - // Limit header lines to prevent DOS attacks - static constexpr size_t MAX_HEADER_LINES = 50; - size_t header_count = 0; - - while (header_count < MAX_HEADER_LINES) { - std::string line = read_line(); - if (line.empty()) { - // Check if we have enough data for a line - auto crlf_pos = find_pattern(reinterpret_cast(CRLF_STR), CRLF_LENGTH); - if (crlf_pos == std::string::npos) { - return false; // Need more data - } - // Empty line means headers are done - buffer_.erase(buffer_.begin(), buffer_.begin() + CRLF_LENGTH); - return true; - } - - process_header_line(line); - header_count++; - } - - ESP_LOGW(TAG, "Too many headers in multipart data"); - state_ = ERROR; - return false; -} - -void MultipartParser::process_header_line(const std::string &line) { - if (str_startswith_case_insensitive(line, "content-disposition:")) { - // Extract name and filename parameters - current_name_ = extract_header_param(line, "name"); - current_filename_ = extract_header_param(line, "filename"); - } else if (str_startswith_case_insensitive(line, "content-type:")) { - current_content_type_ = extract_header_value(line); - } - // RFC 7578: Ignore any other Content-* headers -} - -bool MultipartParser::extract_content() { - // Look for next boundary - std::string search_boundary = CRLF_STR + boundary_; - size_t boundary_pos = - find_pattern(reinterpret_cast(search_boundary.c_str()), search_boundary.length()); - - if (boundary_pos != std::string::npos) { - // Found complete part - content_length_ = boundary_pos; - part_ready_ = true; - return true; - } - - // No boundary found yet, but we might have partial content - // Keep enough bytes to ensure we don't split a boundary - size_t safe_length = buffer_.size(); - if (safe_length > search_boundary.length() + MIN_BOUNDARY_BUFFER) { - safe_length -= search_boundary.length() + MIN_BOUNDARY_BUFFER; - if (safe_length > 0) { - content_length_ = safe_length; - // We have partial content but not complete yet - return false; - } - } - - return false; -} - -std::string MultipartParser::read_line() { - // Limit line length to prevent excessive memory usage - static constexpr size_t MAX_LINE_LENGTH = 4096; - - auto crlf_pos = find_pattern(reinterpret_cast(CRLF_STR), CRLF_LENGTH); - if (crlf_pos == std::string::npos) { - // If we have too much data without CRLF, it's likely malformed - if (buffer_.size() > MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Header line too long, truncating"); - state_ = ERROR; - } - return ""; - } - - if (crlf_pos > MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Header line exceeds maximum length"); - state_ = ERROR; - return ""; - } - - std::string line(buffer_.begin(), buffer_.begin() + crlf_pos); - buffer_.erase(buffer_.begin(), buffer_.begin() + crlf_pos + CRLF_LENGTH); - return line; -} - -size_t MultipartParser::find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start) const { - if (buffer_.size() < pattern_len + start) { - return std::string::npos; - } - - for (size_t i = start; i <= buffer_.size() - pattern_len; ++i) { - if (memcmp(buffer_.data() + i, pattern, pattern_len) == 0) { - return i; - } - } - - return std::string::npos; -} - -} // namespace web_server_idf -} // namespace esphome -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/multipart_parser.h b/esphome/components/web_server_idf/multipart_parser.h deleted file mode 100644 index 480b35a5a1..0000000000 --- a/esphome/components/web_server_idf/multipart_parser.h +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA - -#include -#include -#include - -namespace esphome { -namespace web_server_idf { - -// Multipart form data parser for ESP-IDF -// Implements RFC 7578 compliant multipart/form-data parsing -class MultipartParser { - public: - static constexpr const char *MULTIPART_BOUNDARY_PREFIX = "--"; - - enum State : uint8_t { BOUNDARY_SEARCH, HEADERS, CONTENT, DONE, ERROR }; - - struct Part { - std::string name; - std::string filename; - std::string content_type; - const uint8_t *data; - size_t length; - }; - - explicit MultipartParser(const std::string &boundary) - : boundary_(MULTIPART_BOUNDARY_PREFIX + boundary), - state_(BOUNDARY_SEARCH), - content_length_(0), - part_ready_(false) {} - - // Process incoming data chunk - // Returns true if a complete part is available - bool parse(const uint8_t *data, size_t len); - - // Get the current part if available - bool get_current_part(Part &part) const; - - // Consume the current part and move to next - void consume_part(); - - State get_state() const { return state_; } - bool is_done() const { return state_ == DONE; } - bool has_error() const { return state_ == ERROR; } - - // Reset parser for reuse - void reset(); - - private: - bool find_boundary(); - bool parse_headers(); - void process_header_line(const std::string &line); - bool extract_content(); - - std::string read_line(); - size_t find_pattern(const uint8_t *pattern, size_t pattern_len, size_t start = 0) const; - - std::string boundary_; - State state_; - std::vector buffer_; - - // Current part info - std::string current_name_; - std::string current_filename_; - std::string current_content_type_; - size_t content_length_{0}; - bool part_ready_{false}; -}; - -} // namespace web_server_idf -} // namespace esphome -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp new file mode 100644 index 0000000000..f157fe91e1 --- /dev/null +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -0,0 +1,193 @@ +#ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA +#include "multipart_reader.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome { +namespace web_server_idf { + +static const char *const TAG = "multipart_reader"; + +MultipartReader::MultipartReader(const std::string &boundary) { + // Initialize settings with callbacks + memset(&settings_, 0, sizeof(settings_)); + settings_.on_header_field = on_header_field; + settings_.on_header_value = on_header_value; + settings_.on_part_data_begin = on_part_data_begin; + settings_.on_part_data = on_part_data; + settings_.on_part_data_end = on_part_data_end; + settings_.on_headers_complete = on_headers_complete; + + // Create parser with boundary + parser_ = multipart_parser_init(boundary.c_str(), &settings_); + if (parser_) { + multipart_parser_set_data(parser_, this); + } +} + +MultipartReader::~MultipartReader() { + if (parser_) { + multipart_parser_free(parser_); + } +} + +size_t MultipartReader::parse(const char *data, size_t len) { + if (!parser_) { + return 0; + } + return multipart_parser_execute(parser_, data, len); +} + +int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + // If we were processing a value, save it + if (!reader->current_header_value_.empty()) { + // Process the previous header + std::string field_lower = reader->current_header_field_; + std::transform(field_lower.begin(), field_lower.end(), field_lower.begin(), ::tolower); + + if (field_lower == "content-disposition") { + // Parse name and filename from Content-Disposition + size_t name_pos = reader->current_header_value_.find("name="); + if (name_pos != std::string::npos) { + name_pos += 5; + size_t end_pos; + if (reader->current_header_value_[name_pos] == '"') { + name_pos++; + end_pos = reader->current_header_value_.find('"', name_pos); + } else { + end_pos = reader->current_header_value_.find_first_of("; \r\n", name_pos); + } + if (end_pos != std::string::npos) { + reader->current_part_.name = reader->current_header_value_.substr(name_pos, end_pos - name_pos); + } + } + + size_t filename_pos = reader->current_header_value_.find("filename="); + if (filename_pos != std::string::npos) { + filename_pos += 9; + size_t end_pos; + if (reader->current_header_value_[filename_pos] == '"') { + filename_pos++; + end_pos = reader->current_header_value_.find('"', filename_pos); + } else { + end_pos = reader->current_header_value_.find_first_of("; \r\n", filename_pos); + } + if (end_pos != std::string::npos) { + reader->current_part_.filename = reader->current_header_value_.substr(filename_pos, end_pos - filename_pos); + } + } + } else if (field_lower == "content-type") { + reader->current_part_.content_type = reader->current_header_value_; + } + + reader->current_header_value_.clear(); + } + + // Start new header field + reader->current_header_field_.assign(at, length); + reader->in_headers_ = true; + + return 0; +} + +int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + reader->current_header_value_.append(at, length); + return 0; +} + +int MultipartReader::on_headers_complete(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + // Process last header if any + if (!reader->current_header_value_.empty()) { + std::string field_lower = reader->current_header_field_; + std::transform(field_lower.begin(), field_lower.end(), field_lower.begin(), ::tolower); + + if (field_lower == "content-disposition") { + // Parse name and filename from Content-Disposition + size_t name_pos = reader->current_header_value_.find("name="); + if (name_pos != std::string::npos) { + name_pos += 5; + size_t end_pos; + if (reader->current_header_value_[name_pos] == '"') { + name_pos++; + end_pos = reader->current_header_value_.find('"', name_pos); + } else { + end_pos = reader->current_header_value_.find_first_of("; \r\n", name_pos); + } + if (end_pos != std::string::npos) { + reader->current_part_.name = reader->current_header_value_.substr(name_pos, end_pos - name_pos); + } + } + + size_t filename_pos = reader->current_header_value_.find("filename="); + if (filename_pos != std::string::npos) { + filename_pos += 9; + size_t end_pos; + if (reader->current_header_value_[filename_pos] == '"') { + filename_pos++; + end_pos = reader->current_header_value_.find('"', filename_pos); + } else { + end_pos = reader->current_header_value_.find_first_of("; \r\n", filename_pos); + } + if (end_pos != std::string::npos) { + reader->current_part_.filename = reader->current_header_value_.substr(filename_pos, end_pos - filename_pos); + } + } + } else if (field_lower == "content-type") { + reader->current_part_.content_type = reader->current_header_value_; + } + } + + reader->in_headers_ = false; + reader->current_header_field_.clear(); + reader->current_header_value_.clear(); + + ESP_LOGD(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", + reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(), + reader->current_part_.content_type.c_str()); + + return 0; +} + +int MultipartReader::on_part_data_begin(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + ESP_LOGD(TAG, "Part data begin"); + return 0; +} + +int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + // Only process file uploads + if (reader->has_file() && reader->data_callback_) { + reader->data_callback_(reinterpret_cast(at), length); + } + + return 0; +} + +int MultipartReader::on_part_data_end(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + ESP_LOGD(TAG, "Part data end"); + + if (reader->part_complete_callback_) { + reader->part_complete_callback_(); + } + + // Clear part info for next part + reader->current_part_ = Part{}; + + return 0; +} + +} // namespace web_server_idf +} // namespace esphome +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h new file mode 100644 index 0000000000..e54939e045 --- /dev/null +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -0,0 +1,65 @@ +#pragma once +#ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA + +#include +#include +#include +#include + +namespace esphome { +namespace web_server_idf { + +// Wrapper around zorxx/multipart-parser for ESP-IDF OTA uploads +class MultipartReader { + public: + struct Part { + std::string name; + std::string filename; + std::string content_type; + }; + + using DataCallback = std::function; + using PartCompleteCallback = std::function; + + explicit MultipartReader(const std::string &boundary); + ~MultipartReader(); + + // Set callbacks for handling data + void set_data_callback(DataCallback callback) { data_callback_ = callback; } + void set_part_complete_callback(PartCompleteCallback callback) { part_complete_callback_ = callback; } + + // Parse incoming data + size_t parse(const char *data, size_t len); + + // Get current part info + const Part &get_current_part() const { return current_part_; } + + // Check if we found a file upload + bool has_file() const { return !current_part_.filename.empty(); } + + private: + static int on_header_field(multipart_parser *parser, const char *at, size_t length); + static int on_header_value(multipart_parser *parser, const char *at, size_t length); + static int on_part_data_begin(multipart_parser *parser); + static int on_part_data(multipart_parser *parser, const char *at, size_t length); + static int on_part_data_end(multipart_parser *parser); + static int on_headers_complete(multipart_parser *parser); + + multipart_parser *parser_{nullptr}; + multipart_parser_settings settings_{}; + + Part current_part_; + std::string current_header_field_; + std::string current_header_value_; + + DataCallback data_callback_; + PartCompleteCallback part_complete_callback_; + + bool in_headers_{false}; +}; + +} // namespace web_server_idf +} // namespace esphome +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 93425862d2..775d5727d3 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -9,8 +9,7 @@ #include "utils.h" #ifdef USE_WEBSERVER_OTA -#include "multipart_parser.h" -#include "multipart_parser_utils.h" +#include "multipart_reader.h" #endif #include "web_server_idf.h" @@ -79,16 +78,30 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { #ifdef USE_WEBSERVER_OTA // Check if this is a multipart form data request (for OTA updates) - const char *boundary_start = nullptr; - size_t boundary_len = 0; bool is_multipart = false; + std::string boundary; if (content_type.has_value()) { - const char *ct = content_type.value().c_str(); - is_multipart = parse_multipart_boundary(ct, &boundary_start, &boundary_len); + const std::string &ct = content_type.value(); + size_t boundary_pos = ct.find("boundary="); + if (boundary_pos != std::string::npos) { + boundary_pos += 9; // Skip "boundary=" + size_t boundary_end = ct.find_first_of(" ;\r\n", boundary_pos); + if (boundary_end == std::string::npos) { + boundary_end = ct.length(); + } + if (ct[boundary_pos] == '"' && boundary_end > boundary_pos + 1 && ct[boundary_end - 1] == '"') { + // Quoted boundary + boundary = ct.substr(boundary_pos + 1, boundary_end - boundary_pos - 2); + } else { + // Unquoted boundary + boundary = ct.substr(boundary_pos, boundary_end - boundary_pos); + } + is_multipart = ct.find("multipart/form-data") != std::string::npos && !boundary.empty(); + } - if (!is_multipart && !is_form_urlencoded(ct)) { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct); + if (!is_multipart && ct.find("application/x-www-form-urlencoded") == std::string::npos) { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); // fallback to get handler to support backward compatibility return AsyncWebServer::request_handler(r); } @@ -109,7 +122,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { #ifdef USE_WEBSERVER_OTA // Handle multipart form data - if (is_multipart && boundary_start && boundary_len > 0) { + if (is_multipart && !boundary.empty()) { // Create request object AsyncWebServerRequest req(r); auto *server = static_cast(r->user_ctx); @@ -128,18 +141,36 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_OK; } - // Handle multipart upload - create boundary string only when needed - std::string boundary(boundary_start, boundary_len); - MultipartParser parser(boundary); + // Handle multipart upload using the multipart-parser library + MultipartReader reader(boundary); static constexpr size_t CHUNK_SIZE = 1024; - uint8_t *chunk_buf = new uint8_t[CHUNK_SIZE]; + char *chunk_buf = new char[CHUNK_SIZE]; size_t total_len = r->content_len; size_t remaining = total_len; - bool first_part = true; + std::string current_filename; + bool upload_started = false; + + // Set up callbacks for the multipart reader + reader.set_data_callback([&](const uint8_t *data, size_t len) { + if (!current_filename.empty()) { + found_handler->handleUpload(&req, current_filename, upload_started ? 1 : 0, const_cast(data), len, + false); + upload_started = true; + } + }); + + reader.set_part_complete_callback([&]() { + if (!current_filename.empty() && upload_started) { + // Signal end of this part + found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, false); + current_filename.clear(); + upload_started = false; + } + }); while (remaining > 0) { size_t to_read = std::min(remaining, CHUNK_SIZE); - int recv_len = httpd_req_recv(r, reinterpret_cast(chunk_buf), to_read); + int recv_len = httpd_req_recv(r, chunk_buf, to_read); if (recv_len <= 0) { delete[] chunk_buf; @@ -152,23 +183,25 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } // Parse multipart data - if (parser.parse(chunk_buf, recv_len)) { - MultipartParser::Part part; - if (parser.get_current_part(part) && !part.filename.empty()) { - // This is a file upload - found_handler->handleUpload(&req, part.filename, first_part ? 0 : 1, const_cast(part.data), - part.length, false); - first_part = false; - parser.consume_part(); - } + size_t parsed = reader.parse(chunk_buf, recv_len); + if (parsed != recv_len) { + ESP_LOGW(TAG, "Multipart parser error at byte %zu", total_len - remaining + parsed); + delete[] chunk_buf; + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + // Check if we found a new file part + if (reader.has_file() && current_filename.empty()) { + current_filename = reader.get_current_part().filename; } remaining -= recv_len; } - // Final call to handler - if (!first_part) { - found_handler->handleUpload(&req, "", 2, nullptr, 0, true); + // Final cleanup - send final signal if upload was in progress + if (!current_filename.empty() && upload_started) { + found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); } delete[] chunk_buf; From b70188ba4bb72b6bb08d417c280a1da3a898fb79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 14:40:13 -0500 Subject: [PATCH 16/93] cleanup --- .../web_server_idf/multipart_reader.cpp | 90 +++---------------- .../web_server_idf/multipart_reader.h | 2 + 2 files changed, 15 insertions(+), 77 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index f157fe91e1..217887022c 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -1,9 +1,9 @@ #ifdef USE_ESP_IDF #ifdef USE_WEBSERVER_OTA #include "multipart_reader.h" +#include "multipart_parser_utils.h" #include "esphome/core/log.h" #include -#include namespace esphome { namespace web_server_idf { @@ -40,50 +40,22 @@ size_t MultipartReader::parse(const char *data, size_t len) { return multipart_parser_execute(parser_, data, len); } +void MultipartReader::process_header_() { + if (str_startswith_case_insensitive(current_header_field_, "content-disposition")) { + // Parse name and filename from Content-Disposition + current_part_.name = extract_header_param(current_header_value_, "name"); + current_part_.filename = extract_header_param(current_header_value_, "filename"); + } else if (str_startswith_case_insensitive(current_header_field_, "content-type")) { + current_part_.content_type = str_trim(current_header_value_); + } +} + int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); // If we were processing a value, save it if (!reader->current_header_value_.empty()) { - // Process the previous header - std::string field_lower = reader->current_header_field_; - std::transform(field_lower.begin(), field_lower.end(), field_lower.begin(), ::tolower); - - if (field_lower == "content-disposition") { - // Parse name and filename from Content-Disposition - size_t name_pos = reader->current_header_value_.find("name="); - if (name_pos != std::string::npos) { - name_pos += 5; - size_t end_pos; - if (reader->current_header_value_[name_pos] == '"') { - name_pos++; - end_pos = reader->current_header_value_.find('"', name_pos); - } else { - end_pos = reader->current_header_value_.find_first_of("; \r\n", name_pos); - } - if (end_pos != std::string::npos) { - reader->current_part_.name = reader->current_header_value_.substr(name_pos, end_pos - name_pos); - } - } - - size_t filename_pos = reader->current_header_value_.find("filename="); - if (filename_pos != std::string::npos) { - filename_pos += 9; - size_t end_pos; - if (reader->current_header_value_[filename_pos] == '"') { - filename_pos++; - end_pos = reader->current_header_value_.find('"', filename_pos); - } else { - end_pos = reader->current_header_value_.find_first_of("; \r\n", filename_pos); - } - if (end_pos != std::string::npos) { - reader->current_part_.filename = reader->current_header_value_.substr(filename_pos, end_pos - filename_pos); - } - } - } else if (field_lower == "content-type") { - reader->current_part_.content_type = reader->current_header_value_; - } - + reader->process_header_(); reader->current_header_value_.clear(); } @@ -105,43 +77,7 @@ int MultipartReader::on_headers_complete(multipart_parser *parser) { // Process last header if any if (!reader->current_header_value_.empty()) { - std::string field_lower = reader->current_header_field_; - std::transform(field_lower.begin(), field_lower.end(), field_lower.begin(), ::tolower); - - if (field_lower == "content-disposition") { - // Parse name and filename from Content-Disposition - size_t name_pos = reader->current_header_value_.find("name="); - if (name_pos != std::string::npos) { - name_pos += 5; - size_t end_pos; - if (reader->current_header_value_[name_pos] == '"') { - name_pos++; - end_pos = reader->current_header_value_.find('"', name_pos); - } else { - end_pos = reader->current_header_value_.find_first_of("; \r\n", name_pos); - } - if (end_pos != std::string::npos) { - reader->current_part_.name = reader->current_header_value_.substr(name_pos, end_pos - name_pos); - } - } - - size_t filename_pos = reader->current_header_value_.find("filename="); - if (filename_pos != std::string::npos) { - filename_pos += 9; - size_t end_pos; - if (reader->current_header_value_[filename_pos] == '"') { - filename_pos++; - end_pos = reader->current_header_value_.find('"', filename_pos); - } else { - end_pos = reader->current_header_value_.find_first_of("; \r\n", filename_pos); - } - if (end_pos != std::string::npos) { - reader->current_part_.filename = reader->current_header_value_.substr(filename_pos, end_pos - filename_pos); - } - } - } else if (field_lower == "content-type") { - reader->current_part_.content_type = reader->current_header_value_; - } + reader->process_header_(); } reader->in_headers_ = false; diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index e54939e045..2794e73d9c 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -57,6 +57,8 @@ class MultipartReader { PartCompleteCallback part_complete_callback_; bool in_headers_{false}; + + void process_header_(); }; } // namespace web_server_idf From 80dd6c111de1b9bbefc7601f3cd674c15f177b84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 14:44:47 -0500 Subject: [PATCH 17/93] cleanup --- .../web_server_base/web_server_base.cpp | 8 ++----- .../web_server_idf/web_server_idf.cpp | 24 ++++++------------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index e6d04b16ef..1ed1ef89d8 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -174,12 +174,8 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { request->send(response); #endif #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - AsyncWebServerResponse *response; - if (this->ota_started_ && this->ota_backend_) { - response = request->beginResponse(200, "text/plain", "Update Successful!"); - } else { - response = request->beginResponse(200, "text/plain", "Update Failed!"); - } + AsyncWebServerResponse *response = request->beginResponse( + 200, "text/plain", (this->ota_started_ && this->ota_backend_) ? "Update Successful!" : "Update Failed!"); response->addHeader("Connection", "close"); request->send(response); #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 775d5727d3..ae97ada95f 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -10,6 +10,7 @@ #include "utils.h" #ifdef USE_WEBSERVER_OTA #include "multipart_reader.h" +#include "multipart_parser_utils.h" #endif #include "web_server_idf.h" @@ -83,24 +84,13 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { if (content_type.has_value()) { const std::string &ct = content_type.value(); - size_t boundary_pos = ct.find("boundary="); - if (boundary_pos != std::string::npos) { - boundary_pos += 9; // Skip "boundary=" - size_t boundary_end = ct.find_first_of(" ;\r\n", boundary_pos); - if (boundary_end == std::string::npos) { - boundary_end = ct.length(); - } - if (ct[boundary_pos] == '"' && boundary_end > boundary_pos + 1 && ct[boundary_end - 1] == '"') { - // Quoted boundary - boundary = ct.substr(boundary_pos + 1, boundary_end - boundary_pos - 2); - } else { - // Unquoted boundary - boundary = ct.substr(boundary_pos, boundary_end - boundary_pos); - } - is_multipart = ct.find("multipart/form-data") != std::string::npos && !boundary.empty(); - } + const char *boundary_start = nullptr; + size_t boundary_len = 0; - if (!is_multipart && ct.find("application/x-www-form-urlencoded") == std::string::npos) { + if (parse_multipart_boundary(ct.c_str(), &boundary_start, &boundary_len)) { + boundary.assign(boundary_start, boundary_len); + is_multipart = true; + } else if (!is_form_urlencoded(ct.c_str())) { ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); // fallback to get handler to support backward compatibility return AsyncWebServer::request_handler(r); From 947456628e144216e2c8f74a53f3761261ea42b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 14:51:01 -0500 Subject: [PATCH 18/93] cleanup --- esphome/components/web_server_idf/multipart_reader.cpp | 2 +- esphome/components/web_server_idf/multipart_reader.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 217887022c..9444166100 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -126,4 +126,4 @@ int MultipartReader::on_part_data_end(multipart_parser *parser) { } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file +#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index 2794e73d9c..5d959b3f41 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -64,4 +64,4 @@ class MultipartReader { } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file +#endif // USE_ESP_IDF From 344297b0a780885216e44c1feca9d4c0472aa3d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 14:51:24 -0500 Subject: [PATCH 19/93] cleanup --- .../components/web_server_idf/multipart_parser_utils.h | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index 616f388c54..e552b2b7de 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -209,15 +209,6 @@ inline std::string str_trim(const std::string &str) { return str.substr(start, end - start + 1); } -// Extract header value (everything after the colon) -inline std::string extract_header_value(const std::string &header) { - size_t colon_pos = header.find(':'); - if (colon_pos == std::string::npos) { - return ""; - } - return str_trim(header.substr(colon_pos + 1)); -} - } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA From 3433ee81711302a9619eb5ef6e90906f01ed5dcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 14:59:41 -0500 Subject: [PATCH 20/93] cleanup --- .../web_server_base/web_server_base.cpp | 65 +++++++++---------- .../web_server_base/web_server_base.h | 4 ++ 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 1ed1ef89d8..b504b08525 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,7 +14,7 @@ #endif #endif -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#ifdef USE_WEBSERVER_OTA #include "esphome/components/ota/ota_backend.h" #endif @@ -23,6 +23,21 @@ namespace web_server_base { static const char *const TAG = "web_server_base"; +#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; + } +} +#endif + void WebServerBase::add_handler(AsyncWebHandler *handler) { // remove all handlers @@ -45,6 +60,7 @@ void report_ota_error() { void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) { +#ifdef USE_WEBSERVER_OTA #ifdef USE_ARDUINO bool success; if (index == 0) { @@ -76,17 +92,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin return; } this->ota_read_length_ += len; - - 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; - } + this->report_ota_progress_(request); if (final) { if (Update.end(true)) { @@ -96,9 +102,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin report_ota_error(); } } -#endif +#endif // USE_ARDUINO -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#ifdef USE_ESP_IDF // ESP-IDF implementation if (index == 0) { ESP_LOGI(TAG, "OTA Update Start: %s", filename.c_str()); @@ -133,17 +139,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } this->ota_read_length_ += len; - - 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; - } + this->report_ota_progress_(request); } if (final) { @@ -157,11 +153,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin this->ota_backend_.reset(); this->ota_started_ = false; } -#endif +#endif // USE_ESP_IDF +#endif // USE_WEBSERVER_OTA } void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { -#ifdef USE_ARDUINO +#ifdef USE_WEBSERVER_OTA AsyncWebServerResponse *response; +#ifdef USE_ARDUINO if (!Update.hasError()) { response = request->beginResponse(200, "text/plain", "Update Successful!"); } else { @@ -170,19 +168,18 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { Update.printError(ss); response = request->beginResponse(200, "text/plain", ss); } - response->addHeader("Connection", "close"); - request->send(response); -#endif -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - AsyncWebServerResponse *response = request->beginResponse( +#endif // USE_ARDUINO +#ifdef USE_ESP_IDF + response = request->beginResponse( 200, "text/plain", (this->ota_started_ && this->ota_backend_) ? "Update Successful!" : "Update Failed!"); +#endif // USE_ESP_IDF response->addHeader("Connection", "close"); request->send(response); -#endif +#endif // USE_WEBSERVER_OTA } void WebServerBase::add_ota_handler() { -#if defined(USE_ARDUINO) || (defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA)) +#ifdef USE_WEBSERVER_OTA this->add_handler(new OTARequestHandler(this)); // NOLINT #endif } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 75876109b5..61add4ecea 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -139,8 +139,12 @@ class OTARequestHandler : public AsyncWebHandler { bool isRequestHandlerTrivial() const override { return false; } protected: +#ifdef USE_WEBSERVER_OTA + void report_ota_progress_(AsyncWebServerRequest *request); + uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; +#endif WebServerBase *parent_; #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) std::unique_ptr ota_backend_; From c17503abd51c47152047f41882d89fe415ec86bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 15:03:48 -0500 Subject: [PATCH 21/93] cleanup --- .../web_server_base/web_server_base.cpp | 22 ++++++++++++------- .../web_server_base/web_server_base.h | 2 ++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index b504b08525..052bc5df26 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -36,6 +36,16 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { this->last_ota_progress_ = now; } } + +void OTARequestHandler::schedule_ota_reboot_() { + ESP_LOGI(TAG, "OTA update successful!"); + this->parent_->set_timeout(100, []() { App.safe_reboot(); }); +} + +void OTARequestHandler::ota_init_(const char *filename) { + ESP_LOGI(TAG, "OTA Update Start: %s", filename); + this->ota_read_length_ = 0; +} #endif void WebServerBase::add_handler(AsyncWebHandler *handler) { @@ -64,8 +74,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin #ifdef USE_ARDUINO bool success; if (index == 0) { - ESP_LOGI(TAG, "OTA Update Start: %s", filename.c_str()); - this->ota_read_length_ = 0; + this->ota_init_(filename.c_str()); #ifdef USE_ESP8266 Update.runAsync(true); // NOLINTNEXTLINE(readability-static-accessed-through-instance) @@ -96,8 +105,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin if (final) { if (Update.end(true)) { - ESP_LOGI(TAG, "OTA update successful!"); - this->parent_->set_timeout(100, []() { App.safe_reboot(); }); + this->schedule_ota_reboot_(); } else { report_ota_error(); } @@ -107,8 +115,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin #ifdef USE_ESP_IDF // ESP-IDF implementation if (index == 0) { - ESP_LOGI(TAG, "OTA Update Start: %s", filename.c_str()); - this->ota_read_length_ = 0; + this->ota_init_(filename.c_str()); this->ota_started_ = false; // Create OTA backend @@ -145,8 +152,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin if (final) { auto result = this->ota_backend_->end(); if (result == ota::OTA_RESPONSE_OK) { - ESP_LOGI(TAG, "OTA update successful!"); - this->parent_->set_timeout(100, []() { App.safe_reboot(); }); + this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", result); } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 61add4ecea..965a36e929 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -141,6 +141,8 @@ class OTARequestHandler : public AsyncWebHandler { protected: #ifdef USE_WEBSERVER_OTA 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}; From 3162bb475da04b47761b0472eb6a893e51f7fa95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 15:08:27 -0500 Subject: [PATCH 22/93] cleanup --- esphome/components/web_server_idf/web_server_idf.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index ae97ada95f..323f54c895 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP_IDF #include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -134,7 +135,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { // Handle multipart upload using the multipart-parser library MultipartReader reader(boundary); static constexpr size_t CHUNK_SIZE = 1024; - char *chunk_buf = new char[CHUNK_SIZE]; + std::unique_ptr chunk_buf(new char[CHUNK_SIZE]); size_t total_len = r->content_len; size_t remaining = total_len; std::string current_filename; @@ -160,10 +161,9 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { while (remaining > 0) { size_t to_read = std::min(remaining, CHUNK_SIZE); - int recv_len = httpd_req_recv(r, chunk_buf, to_read); + int recv_len = httpd_req_recv(r, chunk_buf.get(), to_read); if (recv_len <= 0) { - delete[] chunk_buf; if (recv_len == HTTPD_SOCK_ERR_TIMEOUT) { httpd_resp_send_err(r, HTTPD_408_REQ_TIMEOUT, nullptr); return ESP_ERR_TIMEOUT; @@ -173,10 +173,9 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } // Parse multipart data - size_t parsed = reader.parse(chunk_buf, recv_len); + size_t parsed = reader.parse(chunk_buf.get(), recv_len); if (parsed != recv_len) { ESP_LOGW(TAG, "Multipart parser error at byte %zu", total_len - remaining + parsed); - delete[] chunk_buf; httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; } @@ -194,8 +193,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); } - delete[] chunk_buf; - // Let handler send response found_handler->handleRequest(&req); return ESP_OK; From 78fd0a4870bee535b6a83f623802780e1a39ff38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 15:23:32 -0500 Subject: [PATCH 23/93] cleanup --- .../web_server_base/web_server_base.cpp | 27 +++++++++++-------- .../web_server_base/web_server_base.h | 4 ++- esphome/components/web_server_idf/__init__.py | 5 ++-- .../web_server_idf/web_server_idf.cpp | 4 +-- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 052bc5df26..1d4fc2060b 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,7 +14,7 @@ #endif #endif -#ifdef USE_WEBSERVER_OTA +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include "esphome/components/ota/ota_backend.h" #endif @@ -119,15 +119,17 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin this->ota_started_ = false; // Create OTA backend - this->ota_backend_ = ota::make_ota_backend(); + auto backend = ota::make_ota_backend(); // Begin OTA with unknown size - auto result = this->ota_backend_->begin(0); + auto result = backend->begin(0); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", result); - this->ota_backend_.reset(); return; } + + // Store the backend pointer + this->ota_backend_ = backend.release(); this->ota_started_ = true; } else if (!this->ota_started_ || !this->ota_backend_) { // Begin failed or was aborted @@ -136,11 +138,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // Write data if (len > 0) { - auto result = this->ota_backend_->write(data, len); + auto *backend = static_cast(this->ota_backend_); + auto result = backend->write(data, len); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", result); - this->ota_backend_->abort(); - this->ota_backend_.reset(); + backend->abort(); + delete backend; + this->ota_backend_ = nullptr; this->ota_started_ = false; return; } @@ -150,13 +154,15 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } if (final) { - auto result = this->ota_backend_->end(); + auto *backend = static_cast(this->ota_backend_); + auto result = backend->end(); if (result == ota::OTA_RESPONSE_OK) { this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", result); } - this->ota_backend_.reset(); + delete backend; + this->ota_backend_ = nullptr; this->ota_started_ = false; } #endif // USE_ESP_IDF @@ -176,8 +182,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { } #endif // USE_ARDUINO #ifdef USE_ESP_IDF - response = request->beginResponse( - 200, "text/plain", (this->ota_started_ && this->ota_backend_) ? "Update Successful!" : "Update Failed!"); + response = request->beginResponse(200, "text/plain", this->ota_started_ ? "Update Successful!" : "Update Failed!"); #endif // USE_ESP_IDF response->addHeader("Connection", "close"); request->send(response); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 965a36e929..de6d129f7a 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -148,8 +148,10 @@ class OTARequestHandler : public AsyncWebHandler { uint32_t ota_read_length_{0}; #endif WebServerBase *parent_; + + private: #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - std::unique_ptr ota_backend_; + void *ota_backend_{nullptr}; // Actually ota::OTABackend*, stored as void* to avoid incomplete type issues bool ota_started_{false}; #endif }; diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 03f8e60715..6475a60ad8 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,6 +1,5 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option import esphome.config_validation as cv -from esphome.const import CONF_OTA from esphome.core import CORE CODEOWNERS = ["@dentra"] @@ -17,6 +16,6 @@ async def to_code(config): # Check if web_server component has OTA enabled web_server_config = CORE.config.get("web_server", {}) - if web_server_config.get(CONF_OTA, True): # OTA is enabled by default - # Add multipart parser component for OTA support + if web_server_config and web_server_config.get("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/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 323f54c895..83a68a938b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -9,13 +9,13 @@ #include "esp_tls_crypto.h" #include "utils.h" +#include "web_server_idf.h" + #ifdef USE_WEBSERVER_OTA #include "multipart_reader.h" #include "multipart_parser_utils.h" #endif -#include "web_server_idf.h" - #ifdef USE_WEBSERVER #include "esphome/components/web_server/web_server.h" #include "esphome/components/web_server/list_entities.h" From d73fa370f33f1394c93a6a4690feef4e3fea722a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 15:35:59 -0500 Subject: [PATCH 24/93] cleanup --- esphome/components/web_server_idf/__init__.py | 3 ++- esphome/components/web_server_idf/multipart_reader.cpp | 1 + esphome/idf_component.yml | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 6475a60ad8..b4a07da3e1 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,5 +1,6 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option import esphome.config_validation as cv +from esphome.const import CONF_OTA from esphome.core import CORE CODEOWNERS = ["@dentra"] @@ -16,6 +17,6 @@ async def to_code(config): # Check if web_server component has OTA enabled web_server_config = CORE.config.get("web_server", {}) - if web_server_config and web_server_config.get("ota", True): + if web_server_config and web_server_config[CONF_OTA]: # Add multipart parser component for ESP-IDF OTA support add_idf_component(name="zorxx/multipart-parser", ref="1.0.1") diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 9444166100..73ba79e890 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -4,6 +4,7 @@ #include "multipart_parser_utils.h" #include "esphome/core/log.h" #include +#include "multipart_parser.h" namespace esphome { namespace web_server_idf { diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6299909033..c43b622684 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -17,3 +17,5 @@ dependencies: version: 2.0.11 rules: - if: "target in [esp32h2, esp32p4]" + zorxx/multipart-parser: + version: 1.0.1 From 3467329a7c5ddc8b4f43c6db08140d7f7d067735 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 15:43:41 -0500 Subject: [PATCH 25/93] cleanup --- esphome/components/web_server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 733b53b039..d2eabe2cd3 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -261,7 +261,7 @@ async def to_code(config): 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]: + if config[CONF_OTA] and "ota" in CORE.config: cg.add_define("USE_WEBSERVER_OTA") cg.add(var.set_expose_log(config[CONF_LOG])) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: From 5c0d67ca142e7c0503ada41ab94348cb0b73efa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 15:50:12 -0500 Subject: [PATCH 26/93] fixes --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8abd6598f7..f9339b6dc7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -150,6 +150,7 @@ #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER +#define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WIFI_11KV_SUPPORT From ad2d48e9b73b6aad05c12e479542da51d77a315a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 16:03:05 -0500 Subject: [PATCH 27/93] fixes --- esphome/components/web_server_idf/__init__.py | 2 +- esphome/components/web_server_idf/multipart_parser_utils.h | 1 + esphome/components/web_server_idf/multipart_reader.cpp | 1 + esphome/components/web_server_idf/multipart_reader.h | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index b4a07da3e1..dfb32107e8 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -17,6 +17,6 @@ async def to_code(config): # Check if web_server component has OTA enabled web_server_config = CORE.config.get("web_server", {}) - if web_server_config and web_server_config[CONF_OTA]: + if web_server_config and web_server_config[CONF_OTA] and "ota" in CORE.config: # Add multipart parser component for ESP-IDF OTA support add_idf_component(name="zorxx/multipart-parser", ref="1.0.1") diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index e552b2b7de..5787e3d880 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -1,4 +1,5 @@ #pragma once +#include "esphome/core/defines.h" #ifdef USE_ESP_IDF #ifdef USE_WEBSERVER_OTA diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 73ba79e890..435308ea54 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/defines.h" #ifdef USE_ESP_IDF #ifdef USE_WEBSERVER_OTA #include "multipart_reader.h" diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index 5d959b3f41..be82e8a1a5 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -1,4 +1,5 @@ #pragma once +#include "esphome/core/defines.h" #ifdef USE_ESP_IDF #ifdef USE_WEBSERVER_OTA From a963f9752001e920c4a7e4ac9874d22e6ff62a4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 16:07:26 -0500 Subject: [PATCH 28/93] fixes --- .../components/web_server/test_no_ota.esp32-idf.yaml | 9 +++++++++ tests/components/web_server/test_ota.esp32-idf.yaml | 12 ++++++++++++ .../web_server/test_ota_disabled.esp32-idf.yaml | 12 ++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 tests/components/web_server/test_no_ota.esp32-idf.yaml create mode 100644 tests/components/web_server/test_ota.esp32-idf.yaml create mode 100644 tests/components/web_server/test_ota_disabled.esp32-idf.yaml diff --git a/tests/components/web_server/test_no_ota.esp32-idf.yaml b/tests/components/web_server/test_no_ota.esp32-idf.yaml new file mode 100644 index 0000000000..1f677fb948 --- /dev/null +++ b/tests/components/web_server/test_no_ota.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + device_base: !include common.yaml + +# No OTA component defined for this test + +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 new file mode 100644 index 0000000000..198b826ec6 --- /dev/null +++ b/tests/components/web_server/test_ota.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + device_base: !include common.yaml + +# Enable OTA for this test +ota: + - platform: esphome + safe_mode: true + +web_server: + port: 8080 + version: 2 + ota: true diff --git a/tests/components/web_server/test_ota_disabled.esp32-idf.yaml b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml new file mode 100644 index 0000000000..db1a181ddd --- /dev/null +++ b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + device_base: !include common.yaml + +# OTA is configured but web_server OTA is disabled +ota: + - platform: esphome + safe_mode: true + +web_server: + port: 8080 + version: 2 + ota: false From 19f7e3675392679fa74b1b5759ce7b706e4ce1bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 16:10:58 -0500 Subject: [PATCH 29/93] fixes --- .../web_server_idf/multipart_parser_utils.h | 186 +----------------- 1 file changed, 8 insertions(+), 178 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index 5787e3d880..d58232a067 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -14,201 +14,31 @@ namespace web_server_idf { inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } // Helper function for case-insensitive string region comparison -inline bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { - for (size_t i = 0; i < n; i++) { - if (!char_equals_ci(s1[i], s2[i])) { - return false; - } - } - return true; -} +bool str_ncmp_ci(const char *s1, const char *s2, size_t n); // Case-insensitive string prefix check -inline bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { - if (str.length() < prefix.length()) { - return false; - } - return str_ncmp_ci(str.c_str(), prefix.c_str(), prefix.length()); -} +bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix); // Find a substring case-insensitively -inline size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos = 0) { - if (needle.empty() || pos >= haystack.length()) { - return std::string::npos; - } - - const size_t needle_len = needle.length(); - const size_t max_pos = haystack.length() - needle_len; - - for (size_t i = pos; i <= max_pos; i++) { - if (str_ncmp_ci(haystack.c_str() + i, needle.c_str(), needle_len)) { - return i; - } - } - - return std::string::npos; -} +size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos = 0); // Extract a parameter value from a header line // Handles both quoted and unquoted values -inline std::string extract_header_param(const std::string &header, const std::string ¶m) { - size_t search_pos = 0; - - while (search_pos < header.length()) { - // Look for param name - size_t pos = str_find_case_insensitive(header, param, search_pos); - if (pos == std::string::npos) { - return ""; - } - - // Check if this is a word boundary (not part of another parameter) - if (pos > 0 && header[pos - 1] != ' ' && header[pos - 1] != ';' && header[pos - 1] != '\t') { - search_pos = pos + 1; - continue; - } - - // Move past param name - pos += param.length(); - - // Skip whitespace and find '=' - while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { - pos++; - } - - if (pos >= header.length() || header[pos] != '=') { - search_pos = pos; - continue; - } - - pos++; // Skip '=' - - // Skip whitespace after '=' - while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { - pos++; - } - - if (pos >= header.length()) { - return ""; - } - - // Check if value is quoted - if (header[pos] == '"') { - pos++; - size_t end = header.find('"', pos); - if (end != std::string::npos) { - return header.substr(pos, end - pos); - } - // Malformed - no closing quote - return ""; - } - - // Unquoted value - find the end (semicolon, comma, or end of string) - size_t end = pos; - while (end < header.length() && header[end] != ';' && header[end] != ',' && header[end] != ' ' && - header[end] != '\t') { - end++; - } - - return header.substr(pos, end - pos); - } - - return ""; -} +std::string extract_header_param(const std::string &header, const std::string ¶m); // Case-insensitive string search (like strstr but case-insensitive) -inline const char *stristr(const char *haystack, const char *needle) { - if (!haystack || !needle) { - return nullptr; - } - - size_t needle_len = strlen(needle); - if (needle_len == 0) { - return haystack; - } - - for (const char *p = haystack; *p; p++) { - if (str_ncmp_ci(p, needle, needle_len)) { - return p; - } - } - - return nullptr; -} +const char *stristr(const char *haystack, const char *needle); // Parse boundary from Content-Type header // Returns true if boundary found, false otherwise // boundary_start and boundary_len will point to the boundary value -inline bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len) { - if (!content_type) { - return false; - } - - // Check for multipart/form-data (case-insensitive) - if (!stristr(content_type, "multipart/form-data")) { - return false; - } - - // Look for boundary parameter - const char *b = stristr(content_type, "boundary="); - if (!b) { - return false; - } - - const char *start = b + 9; // Skip "boundary=" - - // Skip whitespace - while (*start == ' ' || *start == '\t') { - start++; - } - - if (!*start) { - return false; - } - - // Find end of boundary - const char *end = start; - if (*end == '"') { - // Quoted boundary - start++; - end++; - while (*end && *end != '"') { - end++; - } - *boundary_len = end - start; - } else { - // Unquoted boundary - while (*end && *end != ' ' && *end != ';' && *end != '\r' && *end != '\n' && *end != '\t') { - end++; - } - *boundary_len = end - start; - } - - if (*boundary_len == 0) { - return false; - } - - *boundary_start = start; - return true; -} +bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len); // Check if content type is form-urlencoded (case-insensitive) -inline bool is_form_urlencoded(const char *content_type) { - if (!content_type) { - return false; - } - - return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; -} +bool is_form_urlencoded(const char *content_type); // Trim whitespace from both ends of a string -inline std::string str_trim(const std::string &str) { - size_t start = str.find_first_not_of(" \t\r\n"); - if (start == std::string::npos) { - return ""; - } - size_t end = str.find_last_not_of(" \t\r\n"); - return str.substr(start, end - start + 1); -} +std::string str_trim(const std::string &str); } // namespace web_server_idf } // namespace esphome From 6cb0d9e0b549376b41bbb06c6a5ab228f815283b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 16:11:33 -0500 Subject: [PATCH 30/93] fixes --- .../web_server_idf/multipart_parser_utils.cpp | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 esphome/components/web_server_idf/multipart_parser_utils.cpp diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart_parser_utils.cpp new file mode 100644 index 0000000000..1d85b3b661 --- /dev/null +++ b/esphome/components/web_server_idf/multipart_parser_utils.cpp @@ -0,0 +1,209 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP_IDF +#ifdef USE_WEBSERVER_OTA +#include "multipart_parser_utils.h" + +namespace esphome { +namespace web_server_idf { + +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { + for (size_t i = 0; i < n; i++) { + if (!char_equals_ci(s1[i], s2[i])) { + return false; + } + } + return true; +} + +// Case-insensitive string prefix check +bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { + if (str.length() < prefix.length()) { + return false; + } + return str_ncmp_ci(str.c_str(), prefix.c_str(), prefix.length()); +} + +// Find a substring case-insensitively +size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos) { + if (needle.empty() || pos >= haystack.length()) { + return std::string::npos; + } + + const size_t needle_len = needle.length(); + const size_t max_pos = haystack.length() - needle_len; + + for (size_t i = pos; i <= max_pos; i++) { + if (str_ncmp_ci(haystack.c_str() + i, needle.c_str(), needle_len)) { + return i; + } + } + + return std::string::npos; +} + +// Extract a parameter value from a header line +// Handles both quoted and unquoted values +std::string extract_header_param(const std::string &header, const std::string ¶m) { + size_t search_pos = 0; + + while (search_pos < header.length()) { + // Look for param name + size_t pos = str_find_case_insensitive(header, param, search_pos); + if (pos == std::string::npos) { + return ""; + } + + // Check if this is a word boundary (not part of another parameter) + if (pos > 0 && header[pos - 1] != ' ' && header[pos - 1] != ';' && header[pos - 1] != '\t') { + search_pos = pos + 1; + continue; + } + + // Move past param name + pos += param.length(); + + // Skip whitespace and find '=' + while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { + pos++; + } + + if (pos >= header.length() || header[pos] != '=') { + search_pos = pos; + continue; + } + + pos++; // Skip '=' + + // Skip whitespace after '=' + while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { + pos++; + } + + if (pos >= header.length()) { + return ""; + } + + // Check if value is quoted + if (header[pos] == '"') { + pos++; + size_t end = header.find('"', pos); + if (end != std::string::npos) { + return header.substr(pos, end - pos); + } + // Malformed - no closing quote + return ""; + } + + // Unquoted value - find the end (semicolon, comma, or end of string) + size_t end = pos; + while (end < header.length() && header[end] != ';' && header[end] != ',' && header[end] != ' ' && + header[end] != '\t') { + end++; + } + + return header.substr(pos, end - pos); + } + + return ""; +} + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle) { + if (!haystack || !needle) { + return nullptr; + } + + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return haystack; + } + + for (const char *p = haystack; *p; p++) { + if (str_ncmp_ci(p, needle, needle_len)) { + return p; + } + } + + return nullptr; +} + +// Parse boundary from Content-Type header +// Returns true if boundary found, false otherwise +// boundary_start and boundary_len will point to the boundary value +bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len) { + if (!content_type) { + return false; + } + + // Check for multipart/form-data (case-insensitive) + if (!stristr(content_type, "multipart/form-data")) { + return false; + } + + // Look for boundary parameter + const char *b = stristr(content_type, "boundary="); + if (!b) { + return false; + } + + const char *start = b + 9; // Skip "boundary=" + + // Skip whitespace + while (*start == ' ' || *start == '\t') { + start++; + } + + if (!*start) { + return false; + } + + // Find end of boundary + const char *end = start; + if (*end == '"') { + // Quoted boundary + start++; + end++; + while (*end && *end != '"') { + end++; + } + *boundary_len = end - start; + } else { + // Unquoted boundary + while (*end && *end != ' ' && *end != ';' && *end != '\r' && *end != '\n' && *end != '\t') { + end++; + } + *boundary_len = end - start; + } + + if (*boundary_len == 0) { + return false; + } + + *boundary_start = start; + return true; +} + +// Check if content type is form-urlencoded (case-insensitive) +bool is_form_urlencoded(const char *content_type) { + if (!content_type) { + return false; + } + + return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; +} + +// Trim whitespace from both ends of a string +std::string str_trim(const std::string &str) { + size_t start = str.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) { + return ""; + } + size_t end = str.find_last_not_of(" \t\r\n"); + return str.substr(start, end - start + 1); +} + +} // namespace web_server_idf +} // namespace esphome +#endif // USE_WEBSERVER_OTA +#endif // USE_ESP_IDF \ No newline at end of file From 81db42942c22c7442936784f099bfcb37de21c1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 16:16:53 -0500 Subject: [PATCH 31/93] Fix crash when event last_event_type is null in web_server --- esphome/components/web_server/web_server.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 9f42253794..32027561c7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1684,11 +1684,14 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa } std::string WebServer::event_state_json_generator(WebServer *web_server, void *source) { - return web_server->event_json((event::Event *) (source), *(((event::Event *) (source))->last_event_type), - DETAIL_STATE); + event::Event *event = (event::Event *) source; + const std::string event_type = event->last_event_type ? *event->last_event_type : ""; + return web_server->event_json(event, event_type, DETAIL_STATE); } std::string WebServer::event_all_json_generator(WebServer *web_server, void *source) { - return web_server->event_json((event::Event *) (source), *(((event::Event *) (source))->last_event_type), DETAIL_ALL); + event::Event *event = (event::Event *) source; + const std::string event_type = event->last_event_type ? *event->last_event_type : ""; + return web_server->event_json(event, event_type, DETAIL_ALL); } std::string WebServer::event_json(event::Event *obj, const std::string &event_type, JsonDetail start_config) { return json::build_json([this, obj, event_type, start_config](JsonObject root) { From 30bafc43bdc6c1be56e195ad785156644fd3f260 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 16:52:55 -0500 Subject: [PATCH 32/93] make bot happy --- esphome/components/web_server/web_server.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 32027561c7..927659e621 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1683,15 +1683,15 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa request->send(404); } +static std::string get_event_type(event::Event *event) { return event->last_event_type ? *event->last_event_type : ""; } + std::string WebServer::event_state_json_generator(WebServer *web_server, void *source) { - event::Event *event = (event::Event *) source; - const std::string event_type = event->last_event_type ? *event->last_event_type : ""; - return web_server->event_json(event, event_type, DETAIL_STATE); + auto *event = static_cast(source); + return web_server->event_json(event, get_event_type(event), DETAIL_STATE); } std::string WebServer::event_all_json_generator(WebServer *web_server, void *source) { - event::Event *event = (event::Event *) source; - const std::string event_type = event->last_event_type ? *event->last_event_type : ""; - return web_server->event_json(event, event_type, DETAIL_ALL); + auto *event = static_cast(source); + return web_server->event_json(event, get_event_type(event), DETAIL_ALL); } std::string WebServer::event_json(event::Event *obj, const std::string &event_type, JsonDetail start_config) { return json::build_json([this, obj, event_type, start_config](JsonObject root) { From 3fca3df75668388a79ba872fe1b82915c7bc3a4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 17:22:33 -0500 Subject: [PATCH 33/93] working --- .../components/ota/ota_backend_esp_idf.cpp | 20 +- esphome/components/ota/ota_backend_esp_idf.h | 3 + .../web_server_base/web_server_base.cpp | 42 +++- .../web_server_base/web_server_base.h | 9 +- .../web_server_idf/multipart_parser_utils.cpp | 5 + .../web_server_idf/multipart_reader.cpp | 44 +++- .../web_server_idf/multipart_reader.h | 6 + .../web_server_idf/web_server_idf.cpp | 128 ++++++++-- .../web_server/test_esp_idf_ota.py | 236 ++++++++++++++++++ .../web_server/test_multipart_ota.py | 182 ++++++++++++++ .../web_server/test_ota.esp32-idf.yaml | 23 +- .../components/web_server/test_ota_readme.md | 70 ++++++ 12 files changed, 740 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/web_server/test_esp_idf_ota.py create mode 100755 tests/components/web_server/test_multipart_ota.py create mode 100644 tests/components/web_server/test_ota_readme.md diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 6f45fb75e4..ee0966d807 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -17,6 +17,10 @@ namespace ota { std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size) { + // Reset MD5 validation state + this->md5_set_ = false; + memset(this->expected_bin_md5_, 0, sizeof(this->expected_bin_md5_)); + this->partition_ = esp_ota_get_next_update_partition(nullptr); if (this->partition_ == nullptr) { return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; @@ -67,7 +71,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); @@ -85,10 +92,15 @@ 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; + + // Only validate MD5 if one was provided + if (this->md5_set_) { + 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; if (err == ESP_OK) { diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index ed66d9b970..e810cd1f9c 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -6,12 +6,14 @@ #include "esphome/core/defines.h" #include +#include namespace esphome { namespace ota { class IDFOTABackend : public OTABackend { public: + IDFOTABackend() : md5_set_(false) { memset(expected_bin_md5_, 0, sizeof(expected_bin_md5_)); } OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; OTAResponseTypes write(uint8_t *data, size_t len) override; @@ -24,6 +26,7 @@ class IDFOTABackend : public OTABackend { const esp_partition_t *partition_; md5::MD5Digest md5_{}; char expected_bin_md5_[32]; + bool md5_set_; }; } // namespace ota diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 1d4fc2060b..631c587391 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -4,6 +4,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_ESP_IDF +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#endif + #ifdef USE_ARDUINO #include #if defined(USE_ESP32) || defined(USE_LIBRETINY) @@ -117,6 +122,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin if (index == 0) { this->ota_init_(filename.c_str()); this->ota_started_ = false; + this->ota_success_ = false; // Create OTA backend auto backend = ota::make_ota_backend(); @@ -125,12 +131,14 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin auto result = backend->begin(0); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", result); + this->ota_success_ = false; return; } // Store the backend pointer this->ota_backend_ = backend.release(); this->ota_started_ = true; + this->ota_success_ = false; // Will be set to true only on successful completion } else if (!this->ota_started_ || !this->ota_backend_) { // Begin failed or was aborted return; @@ -139,6 +147,29 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // Write data if (len > 0) { auto *backend = static_cast(this->ota_backend_); + + // Log first chunk of data received by OTA handler + if (this->ota_read_length_ == 0 && len >= 8) { + ESP_LOGD(TAG, "First data received by OTA handler: %02x %02x %02x %02x %02x %02x %02x %02x", data[0], data[1], + data[2], data[3], data[4], data[5], data[6], data[7]); + ESP_LOGD(TAG, "Data pointer in OTA handler: %p, len: %zu, index: %zu", data, len, index); + } + + // Feed watchdog and yield periodically to prevent timeout during OTA + // Flash writes can be slow, especially for large chunks + static uint32_t last_ota_yield = 0; + static uint32_t ota_chunks_written = 0; + uint32_t now = millis(); + ota_chunks_written++; + + // Yield more frequently during OTA - every 25ms or every 2 chunks + if (now - last_ota_yield > 25 || ota_chunks_written >= 2) { + // Don't log during yield - logging itself can cause delays + vTaskDelay(2); // Let other tasks run for 2 ticks + last_ota_yield = now; + ota_chunks_written = 0; + } + auto result = backend->write(data, len); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", result); @@ -146,6 +177,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin delete backend; this->ota_backend_ = nullptr; this->ota_started_ = false; + this->ota_success_ = false; return; } @@ -157,9 +189,11 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin auto *backend = static_cast(this->ota_backend_); auto result = backend->end(); if (result == ota::OTA_RESPONSE_OK) { + this->ota_success_ = true; this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", result); + this->ota_success_ = false; } delete backend; this->ota_backend_ = nullptr; @@ -170,6 +204,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_OTA + ESP_LOGD(TAG, "OTA handleRequest called"); AsyncWebServerResponse *response; #ifdef USE_ARDUINO if (!Update.hasError()) { @@ -182,7 +217,12 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { } #endif // USE_ARDUINO #ifdef USE_ESP_IDF - response = request->beginResponse(200, "text/plain", this->ota_started_ ? "Update Successful!" : "Update Failed!"); + if (this->ota_success_) { + request->send(200, "text/plain", "Update Successful!"); + } else { + request->send(200, "text/plain", "Update Failed!"); + } + return; #endif // USE_ESP_IDF response->addHeader("Connection", "close"); request->send(response); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index de6d129f7a..ee804674e1 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -127,7 +127,13 @@ class WebServerBase : public Component { class OTARequestHandler : public AsyncWebHandler { public: - OTARequestHandler(WebServerBase *parent) : parent_(parent) {} + OTARequestHandler(WebServerBase *parent) : parent_(parent) { +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) + this->ota_backend_ = nullptr; + this->ota_started_ = false; + this->ota_success_ = false; +#endif + } void handleRequest(AsyncWebServerRequest *request) override; void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) override; @@ -153,6 +159,7 @@ class OTARequestHandler : public AsyncWebHandler { #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) void *ota_backend_{nullptr}; // Actually ota::OTABackend*, stored as void* to avoid incomplete type issues bool ota_started_{false}; + bool ota_success_{false}; #endif }; diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart_parser_utils.cpp index 1d85b3b661..bc548492eb 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.cpp +++ b/esphome/components/web_server_idf/multipart_parser_utils.cpp @@ -2,6 +2,7 @@ #ifdef USE_ESP_IDF #ifdef USE_WEBSERVER_OTA #include "multipart_parser_utils.h" +#include "esphome/core/log.h" namespace esphome { namespace web_server_idf { @@ -181,6 +182,10 @@ bool parse_multipart_boundary(const char *content_type, const char **boundary_st } *boundary_start = start; + + // Debug log the extracted boundary + ESP_LOGD("multipart_utils", "Extracted boundary: '%.*s' (len: %zu)", (int) *boundary_len, start, *boundary_len); + return true; } diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 435308ea54..2f3ea9190d 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -12,7 +12,7 @@ namespace web_server_idf { static const char *const TAG = "multipart_reader"; -MultipartReader::MultipartReader(const std::string &boundary) { +MultipartReader::MultipartReader(const std::string &boundary) : first_data_logged_(false) { // Initialize settings with callbacks memset(&settings_, 0, sizeof(settings_)); settings_.on_header_field = on_header_field; @@ -22,10 +22,14 @@ MultipartReader::MultipartReader(const std::string &boundary) { settings_.on_part_data_end = on_part_data_end; settings_.on_headers_complete = on_headers_complete; + ESP_LOGV(TAG, "Initializing multipart parser with boundary: '%s' (len: %zu)", boundary.c_str(), boundary.length()); + // Create parser with boundary parser_ = multipart_parser_init(boundary.c_str(), &settings_); if (parser_) { multipart_parser_set_data(parser_, this); + } else { + ESP_LOGE(TAG, "Failed to initialize multipart parser"); } } @@ -37,9 +41,26 @@ MultipartReader::~MultipartReader() { size_t MultipartReader::parse(const char *data, size_t len) { if (!parser_) { + ESP_LOGE(TAG, "Parser not initialized"); return 0; } - return multipart_parser_execute(parser_, data, len); + + size_t parsed = multipart_parser_execute(parser_, data, len); + + if (parsed != len) { + ESP_LOGD(TAG, "Parser consumed %zu of %zu bytes", parsed, len); + // Log the data around the error point + if (parsed < len && parsed < 32) { + ESP_LOGD(TAG, "Data at error point (offset %zu): %02x %02x %02x %02x", parsed, + parsed > 0 ? (uint8_t) data[parsed - 1] : 0, (uint8_t) data[parsed], + parsed + 1 < len ? (uint8_t) data[parsed + 1] : 0, parsed + 2 < len ? (uint8_t) data[parsed + 2] : 0); + + // Log what we have vs what parser expects + ESP_LOGD(TAG, "Parser error at position %zu: got '%c' (0x%02x)", parsed, data[parsed], (uint8_t) data[parsed]); + } + } + + return parsed; } void MultipartReader::process_header_() { @@ -95,7 +116,7 @@ int MultipartReader::on_headers_complete(multipart_parser *parser) { int MultipartReader::on_part_data_begin(multipart_parser *parser) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - ESP_LOGD(TAG, "Part data begin"); + ESP_LOGV(TAG, "Part data begin"); return 0; } @@ -104,6 +125,18 @@ int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size // Only process file uploads if (reader->has_file() && reader->data_callback_) { + // IMPORTANT: The 'at' pointer points to data within the parser's input buffer. + // This data is only valid during this callback. The callback handler MUST + // process or copy the data immediately - it cannot store the pointer for + // later use as the buffer will be overwritten. + // Log first data bytes from multipart parser + if (!reader->first_data_logged_ && length >= 8) { + ESP_LOGD(TAG, "First part data from parser: %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) at[0], + (uint8_t) at[1], (uint8_t) at[2], (uint8_t) at[3], (uint8_t) at[4], (uint8_t) at[5], (uint8_t) at[6], + (uint8_t) at[7]); + reader->first_data_logged_ = true; + } + reader->data_callback_(reinterpret_cast(at), length); } @@ -113,7 +146,7 @@ int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size int MultipartReader::on_part_data_end(multipart_parser *parser) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - ESP_LOGD(TAG, "Part data end"); + ESP_LOGV(TAG, "Part data end"); if (reader->part_complete_callback_) { reader->part_complete_callback_(); @@ -122,6 +155,9 @@ int MultipartReader::on_part_data_end(multipart_parser *parser) { // Clear part info for next part reader->current_part_ = Part{}; + // Reset first_data flag for next upload + reader->first_data_logged_ = false; + return 0; } diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index be82e8a1a5..71607cc99b 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -20,6 +20,11 @@ class MultipartReader { std::string content_type; }; + // IMPORTANT: The data pointer in DataCallback is only valid during the callback! + // The multipart parser passes pointers to its internal buffer which will be + // overwritten after the callback returns. Callbacks MUST process or copy the + // data immediately - storing the pointer for deferred processing will result + // in use-after-free bugs. using DataCallback = std::function; using PartCompleteCallback = std::function; @@ -58,6 +63,7 @@ class MultipartReader { PartCompleteCallback part_complete_callback_; bool in_headers_{false}; + bool first_data_logged_{false}; void process_header_(); }; diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 83a68a938b..102cccf298 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -7,6 +7,8 @@ #include "esphome/core/log.h" #include "esp_tls_crypto.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "utils.h" #include "web_server_idf.h" @@ -75,7 +77,7 @@ void AsyncWebServer::begin() { } esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { - ESP_LOGVV(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); + ESP_LOGD(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); auto content_type = request_get_header(r, "Content-Type"); #ifdef USE_WEBSERVER_OTA @@ -91,6 +93,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { if (parse_multipart_boundary(ct.c_str(), &boundary_start, &boundary_len)) { boundary.assign(boundary_start, boundary_len); is_multipart = true; + ESP_LOGD(TAG, "Multipart upload detected, boundary: '%s' (len: %zu)", boundary.c_str(), boundary_len); } else if (!is_form_urlencoded(ct.c_str())) { ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); // fallback to get handler to support backward compatibility @@ -123,42 +126,93 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { for (auto *handler : server->handlers_) { if (handler->canHandle(&req)) { found_handler = handler; + ESP_LOGD(TAG, "Found handler for OTA request"); break; } } if (!found_handler) { + ESP_LOGW(TAG, "No handler found for OTA request"); httpd_resp_send_err(r, HTTPD_404_NOT_FOUND, nullptr); return ESP_OK; } // Handle multipart upload using the multipart-parser library - MultipartReader reader(boundary); + // The multipart data starts with "--" + boundary, so we need to prepend it + std::string full_boundary = "--" + boundary; + ESP_LOGV(TAG, "Initializing multipart reader with full boundary: '%s'", full_boundary.c_str()); + MultipartReader reader(full_boundary); static constexpr size_t CHUNK_SIZE = 1024; + // IMPORTANT: chunk_buf is reused for each chunk read from the socket. + // The multipart parser will pass pointers into this buffer to callbacks. + // Those pointers are only valid during the callback execution! std::unique_ptr chunk_buf(new char[CHUNK_SIZE]); size_t total_len = r->content_len; size_t remaining = total_len; std::string current_filename; bool upload_started = false; + // Track if we've started the upload + bool file_started = false; + // Set up callbacks for the multipart reader reader.set_data_callback([&](const uint8_t *data, size_t len) { - if (!current_filename.empty()) { - found_handler->handleUpload(&req, current_filename, upload_started ? 1 : 0, const_cast(data), len, - false); - upload_started = true; + // CRITICAL: The data pointer is only valid during this callback! + // The multipart parser passes pointers into the chunk_buf buffer, which will be + // overwritten when we read the next chunk. We MUST process the data immediately + // within this callback - any deferred processing will result in use-after-free bugs + // where the data pointer points to corrupted/overwritten memory. + + // By the time on_part_data is called, on_headers_complete has already been called + // so we can check for filename + if (reader.has_file()) { + if (current_filename.empty()) { + // First time we see data for this file + current_filename = reader.get_current_part().filename; + ESP_LOGD(TAG, "Processing file part: '%s'", current_filename.c_str()); + } + + // Log first few bytes of firmware data (only once) + static bool firmware_data_logged = false; + if (!firmware_data_logged && len >= 8) { + ESP_LOGD(TAG, "First firmware bytes from callback: %02x %02x %02x %02x %02x %02x %02x %02x", data[0], data[1], + data[2], data[3], data[4], data[5], data[6], data[7]); + firmware_data_logged = true; + } + + if (!file_started) { + // Initialize the upload with index=0 + ESP_LOGD(TAG, "Starting upload for: '%s'", current_filename.c_str()); + found_handler->handleUpload(&req, current_filename, 0, nullptr, 0, false); + file_started = true; + upload_started = true; + } + + // Process the data chunk immediately - the pointer won't be valid after this callback returns! + // DO NOT store the data pointer for later use or pass it to any async/deferred operations. + if (len > 0) { + found_handler->handleUpload(&req, current_filename, 1, const_cast(data), len, false); + } } }); reader.set_part_complete_callback([&]() { if (!current_filename.empty() && upload_started) { - // Signal end of this part - found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, false); + ESP_LOGD(TAG, "Part complete callback called for: '%s'", current_filename.c_str()); + // Signal end of this part - final=true signals completion + found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); current_filename.clear(); upload_started = false; + file_started = false; } }); + // Track time to yield periodically + uint32_t last_yield = millis(); + static constexpr uint32_t YIELD_INTERVAL_MS = 50; // Yield every 50ms + uint32_t chunks_processed = 0; + static constexpr uint32_t CHUNKS_PER_YIELD = 5; // Also yield every 5 chunks + while (remaining > 0) { size_t to_read = std::min(remaining, CHUNK_SIZE); int recv_len = httpd_req_recv(r, chunk_buf.get(), to_read); @@ -172,29 +226,69 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_FAIL; } - // Parse multipart data - size_t parsed = reader.parse(chunk_buf.get(), recv_len); - if (parsed != recv_len) { - ESP_LOGW(TAG, "Multipart parser error at byte %zu", total_len - remaining + parsed); - httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); - return ESP_FAIL; + // Yield periodically to prevent watchdog timeout + chunks_processed++; + uint32_t now = millis(); + if (now - last_yield > YIELD_INTERVAL_MS || chunks_processed >= CHUNKS_PER_YIELD) { + // Don't log during yield - logging itself can cause delays + vTaskDelay(2); // Yield for 2 ticks to give more time to other tasks + last_yield = now; + chunks_processed = 0; } - // Check if we found a new file part - if (reader.has_file() && current_filename.empty()) { - current_filename = reader.get_current_part().filename; + // Log received vs requested - only log every 100KB to reduce overhead + static size_t bytes_logged = 0; + bytes_logged += recv_len; + if (bytes_logged > 100000) { + ESP_LOGD(TAG, "OTA progress: %zu bytes remaining", remaining); + bytes_logged = 0; + } + // Log first few bytes for debugging + if (total_len == remaining) { + ESP_LOGD(TAG, "First chunk data (hex): %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) chunk_buf[0], + (uint8_t) chunk_buf[1], (uint8_t) chunk_buf[2], (uint8_t) chunk_buf[3], (uint8_t) chunk_buf[4], + (uint8_t) chunk_buf[5], (uint8_t) chunk_buf[6], (uint8_t) chunk_buf[7]); + ESP_LOGD(TAG, "First chunk data (ascii): %.8s", chunk_buf.get()); + ESP_LOGD(TAG, "Expected boundary start: %.8s", full_boundary.c_str()); + + // Log more of the first chunk to see the headers + ESP_LOGD(TAG, "First 256 bytes of upload:"); + for (int i = 0; i < std::min(recv_len, 256); i += 16) { + char hex_buf[50]; + char ascii_buf[17]; + int n = std::min(16, recv_len - i); + for (int j = 0; j < n; j++) { + sprintf(hex_buf + j * 3, "%02x ", (uint8_t) chunk_buf[i + j]); + ascii_buf[j] = isprint(chunk_buf[i + j]) ? chunk_buf[i + j] : '.'; + } + ascii_buf[n] = '\0'; + ESP_LOGD(TAG, "%04x: %-48s %s", i, hex_buf, ascii_buf); + } + } + + size_t parsed = reader.parse(chunk_buf.get(), recv_len); + if (parsed != recv_len) { + ESP_LOGW(TAG, "Multipart parser error at byte %zu (parsed %zu of %d bytes)", total_len - remaining + parsed, + parsed, recv_len); + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; } remaining -= recv_len; } // Final cleanup - send final signal if upload was in progress + // This should not be needed as part_complete_callback should handle it if (!current_filename.empty() && upload_started) { + ESP_LOGW(TAG, "Upload was not properly closed by part_complete_callback"); found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); + file_started = false; } // Let handler send response + ESP_LOGD(TAG, "Calling handleRequest for OTA response"); found_handler->handleRequest(&req); + ESP_LOGD(TAG, "handleRequest completed"); return ESP_OK; } #endif // USE_WEBSERVER_OTA diff --git a/tests/component_tests/web_server/test_esp_idf_ota.py b/tests/component_tests/web_server/test_esp_idf_ota.py new file mode 100644 index 0000000000..f733017440 --- /dev/null +++ b/tests/component_tests/web_server/test_esp_idf_ota.py @@ -0,0 +1,236 @@ +import asyncio +import os +import tempfile + +import aiohttp +import pytest + + +@pytest.fixture +async def web_server_fixture(event_loop): + """Start the test device with web server""" + # This would be replaced with actual device setup in a real test environment + # For now, we'll assume the device is running at a specific address + base_url = "http://localhost:8080" + + # Wait a bit for server to be ready + await asyncio.sleep(2) + + yield base_url + + +async def create_test_firmware(): + """Create a dummy firmware file for testing""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + # Write some dummy data that looks like a firmware file + # ESP32 firmware files typically start with these magic bytes + f.write(b"\xe9\x08\x02\x20") # ESP32 magic bytes + # Add some padding to make it look like a real firmware + f.write(b"\x00" * 1024) # 1KB of zeros + f.write(b"TEST_FIRMWARE_CONTENT") + f.write(b"\x00" * 1024) # More padding + return f.name + + +@pytest.mark.asyncio +async def test_ota_upload_multipart(web_server_fixture): + """Test OTA firmware upload using multipart/form-data""" + base_url = web_server_fixture + firmware_path = await create_test_firmware() + + try: + # Create multipart form data + async with aiohttp.ClientSession() as session: + # First, check if OTA endpoint is available + async with session.get(f"{base_url}/") as resp: + assert resp.status == 200 + content = await resp.text() + assert "ota" in content or "OTA" in content + + # Prepare multipart upload + with open(firmware_path, "rb") as f: + data = aiohttp.FormData() + data.add_field( + "firmware", + f, + filename="firmware.bin", + content_type="application/octet-stream", + ) + + # Send OTA update request + async with session.post(f"{base_url}/ota/upload", data=data) as resp: + assert resp.status in [200, 201, 204], ( + f"OTA upload failed with status {resp.status}" + ) + + # Check response + if resp.status == 200: + response_text = await resp.text() + # The response might be JSON or plain text depending on implementation + assert ( + "success" in response_text.lower() + or "ok" in response_text.lower() + ) + + finally: + # Clean up + os.unlink(firmware_path) + + +@pytest.mark.asyncio +async def test_ota_upload_wrong_content_type(web_server_fixture): + """Test that OTA upload fails with wrong content type""" + base_url = web_server_fixture + + async with aiohttp.ClientSession() as session: + # Try to upload with wrong content type + data = b"not a firmware file" + headers = {"Content-Type": "text/plain"} + + async with session.post( + f"{base_url}/ota/upload", data=data, headers=headers + ) as resp: + # Should fail with bad request or similar + assert resp.status >= 400, f"Expected error status, got {resp.status}" + + +@pytest.mark.asyncio +async def test_ota_upload_empty_file(web_server_fixture): + """Test that OTA upload fails with empty file""" + base_url = web_server_fixture + + async with aiohttp.ClientSession() as session: + # Create empty multipart upload + data = aiohttp.FormData() + data.add_field( + "firmware", + b"", + filename="empty.bin", + content_type="application/octet-stream", + ) + + async with session.post(f"{base_url}/ota/upload", data=data) as resp: + # Should fail with bad request + assert resp.status >= 400, ( + f"Expected error status for empty file, got {resp.status}" + ) + + +@pytest.mark.asyncio +async def test_ota_multipart_boundary_parsing(web_server_fixture): + """Test multipart boundary parsing edge cases""" + base_url = web_server_fixture + firmware_path = await create_test_firmware() + + try: + async with aiohttp.ClientSession() as session: + # Test with custom boundary + with open(firmware_path, "rb") as f: + # Create multipart manually with specific boundary + boundary = "----WebKitFormBoundaryCustomTest123" + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="firmware"; filename="test.bin"\r\n' + f"Content-Type: application/octet-stream\r\n" + f"\r\n" + ).encode() + body += f.read() + body += f"\r\n--{boundary}--\r\n".encode() + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)), + } + + async with session.post( + f"{base_url}/ota/upload", data=body, headers=headers + ) as resp: + assert resp.status in [200, 201, 204], ( + f"Custom boundary upload failed with status {resp.status}" + ) + + finally: + os.unlink(firmware_path) + + +@pytest.mark.asyncio +async def test_ota_concurrent_uploads(web_server_fixture): + """Test that concurrent OTA uploads are properly handled""" + base_url = web_server_fixture + firmware_path = await create_test_firmware() + + try: + async with aiohttp.ClientSession() as session: + # Create two concurrent upload tasks + async def upload_firmware(): + with open(firmware_path, "rb") as f: + data = aiohttp.FormData() + data.add_field( + "firmware", + f.read(), # Read to bytes to avoid file conflicts + filename="firmware.bin", + content_type="application/octet-stream", + ) + + async with session.post( + f"{base_url}/ota/upload", data=data + ) as resp: + return resp.status + + # Start two uploads concurrently + results = await asyncio.gather( + upload_firmware(), upload_firmware(), return_exceptions=True + ) + + # One should succeed, the other should fail with conflict + statuses = [r for r in results if isinstance(r, int)] + assert len(statuses) == 2 + assert 200 in statuses or 201 in statuses or 204 in statuses + # The other might be 409 Conflict or similar + + finally: + os.unlink(firmware_path) + + +@pytest.mark.asyncio +async def test_ota_large_file_upload(web_server_fixture): + """Test OTA upload with a larger file to test chunked processing""" + base_url = web_server_fixture + + # Create a larger test firmware (1MB) + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + # ESP32 magic bytes + f.write(b"\xe9\x08\x02\x20") + # Write 1MB of data in chunks + chunk_size = 4096 + for _ in range(256): # 256 * 4KB = 1MB + f.write(b"A" * chunk_size) + firmware_path = f.name + + try: + async with aiohttp.ClientSession() as session: + with open(firmware_path, "rb") as f: + data = aiohttp.FormData() + data.add_field( + "firmware", + f, + filename="large_firmware.bin", + content_type="application/octet-stream", + ) + + # Use a longer timeout for large file + timeout = aiohttp.ClientTimeout(total=60) + async with session.post( + f"{base_url}/ota/upload", data=data, timeout=timeout + ) as resp: + assert resp.status in [200, 201, 204], ( + f"Large file OTA upload failed with status {resp.status}" + ) + + finally: + os.unlink(firmware_path) + + +if __name__ == "__main__": + # For manual testing + asyncio.run(test_ota_upload_multipart(asyncio.Event())) diff --git a/tests/components/web_server/test_multipart_ota.py b/tests/components/web_server/test_multipart_ota.py new file mode 100755 index 0000000000..84e3264e1b --- /dev/null +++ b/tests/components/web_server/test_multipart_ota.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +Test script for ESP-IDF web server multipart OTA upload functionality. +This script can be run manually to test OTA uploads to a running device. +""" + +import argparse +from pathlib import Path +import sys +import time + +import requests + + +def test_multipart_ota_upload(host, port, firmware_path): + """Test OTA firmware upload using multipart/form-data""" + base_url = f"http://{host}:{port}" + + print(f"Testing OTA upload to {base_url}") + + # First check if server is reachable + try: + resp = requests.get(f"{base_url}/", timeout=5) + if resp.status_code != 200: + print(f"Error: Server returned status {resp.status_code}") + return False + print("✓ Server is reachable") + except requests.exceptions.RequestException as e: + print(f"Error: Cannot reach server - {e}") + return False + + # Check if firmware file exists + if not Path(firmware_path).exists(): + print(f"Error: Firmware file not found: {firmware_path}") + return False + + # Prepare multipart upload + print(f"Uploading firmware: {firmware_path}") + print(f"File size: {Path(firmware_path).stat().st_size} bytes") + + try: + with open(firmware_path, "rb") as f: + files = {"firmware": ("firmware.bin", f, "application/octet-stream")} + + # Send OTA update request + resp = requests.post(f"{base_url}/ota/upload", files=files, timeout=60) + + if resp.status_code in [200, 201, 204]: + print(f"✓ OTA upload successful (status: {resp.status_code})") + if resp.text: + print(f"Response: {resp.text}") + return True + else: + print(f"✗ OTA upload failed with status {resp.status_code}") + print(f"Response: {resp.text}") + return False + + except requests.exceptions.RequestException as e: + print(f"Error during upload: {e}") + return False + + +def test_ota_with_wrong_content_type(host, port): + """Test that OTA upload fails gracefully with wrong content type""" + base_url = f"http://{host}:{port}" + + print("\nTesting OTA with wrong content type...") + + try: + # Send plain text instead of multipart + headers = {"Content-Type": "text/plain"} + resp = requests.post( + f"{base_url}/ota/upload", + data="This is not a firmware file", + headers=headers, + timeout=10, + ) + + if resp.status_code >= 400: + print( + f"✓ Server correctly rejected wrong content type (status: {resp.status_code})" + ) + return True + else: + print(f"✗ Server accepted wrong content type (status: {resp.status_code})") + return False + + except requests.exceptions.RequestException as e: + print(f"Error: {e}") + return False + + +def test_ota_with_empty_file(host, port): + """Test that OTA upload fails gracefully with empty file""" + base_url = f"http://{host}:{port}" + + print("\nTesting OTA with empty file...") + + try: + # Send empty file + files = {"firmware": ("empty.bin", b"", "application/octet-stream")} + resp = requests.post(f"{base_url}/ota/upload", files=files, timeout=10) + + if resp.status_code >= 400: + print( + f"✓ Server correctly rejected empty file (status: {resp.status_code})" + ) + return True + else: + print(f"✗ Server accepted empty file (status: {resp.status_code})") + return False + + except requests.exceptions.RequestException as e: + print(f"Error: {e}") + return False + + +def create_test_firmware(size_kb=10): + """Create a dummy firmware file for testing""" + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + # ESP32 firmware magic bytes + f.write(b"\xe9\x08\x02\x20") + # Add padding + f.write(b"\x00" * (size_kb * 1024 - 4)) + return f.name + + +def main(): + parser = argparse.ArgumentParser( + description="Test ESP-IDF web server OTA functionality" + ) + parser.add_argument("--host", default="localhost", help="Device hostname or IP") + parser.add_argument("--port", type=int, default=8080, help="Web server port") + parser.add_argument( + "--firmware", help="Path to firmware file (if not specified, creates test file)" + ) + parser.add_argument( + "--skip-error-tests", action="store_true", help="Skip error condition tests" + ) + + args = parser.parse_args() + + # Create test firmware if not specified + firmware_path = args.firmware + if not firmware_path: + print("Creating test firmware file...") + firmware_path = create_test_firmware(100) # 100KB test file + print(f"Created test firmware: {firmware_path}") + + all_passed = True + + # Test successful OTA upload + if not test_multipart_ota_upload(args.host, args.port, firmware_path): + all_passed = False + + # Test error conditions + if not args.skip_error_tests: + time.sleep(1) # Small delay between tests + + if not test_ota_with_wrong_content_type(args.host, args.port): + all_passed = False + + time.sleep(1) + + if not test_ota_with_empty_file(args.host, args.port): + all_passed = False + + # Clean up test firmware if we created it + if not args.firmware: + import os + + os.unlink(firmware_path) + print("\nCleaned up test firmware") + + print(f"\n{'All tests passed!' if all_passed else 'Some tests failed!'}") + return 0 if all_passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/components/web_server/test_ota.esp32-idf.yaml b/tests/components/web_server/test_ota.esp32-idf.yaml index 198b826ec6..6147d2b1ed 100644 --- a/tests/components/web_server/test_ota.esp32-idf.yaml +++ b/tests/components/web_server/test_ota.esp32-idf.yaml @@ -1,12 +1,33 @@ +# 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: + type: esp-idf + packages: device_base: !include common.yaml -# Enable OTA for this test +# Enable OTA for multipart upload testing ota: - platform: esphome safe_mode: true + password: "test_ota_password" +# Web server with OTA enabled web_server: port: 8080 version: 2 ota: true + include_internal: true + +# Enable debug logging for OTA +logger: + level: DEBUG + logs: + web_server: VERBOSE + web_server_idf: VERBOSE + diff --git a/tests/components/web_server/test_ota_readme.md b/tests/components/web_server/test_ota_readme.md new file mode 100644 index 0000000000..bb93db6e06 --- /dev/null +++ b/tests/components/web_server/test_ota_readme.md @@ -0,0 +1,70 @@ +# Testing ESP-IDF Web Server OTA Functionality + +This directory contains tests for the ESP-IDF web server OTA (Over-The-Air) update functionality using multipart form uploads. + +## Test Files + +- `test_ota.esp32-idf.yaml` - ESPHome configuration with OTA enabled for ESP-IDF +- `test_no_ota.esp32-idf.yaml` - ESPHome configuration with OTA disabled +- `test_ota_disabled.esp32-idf.yaml` - ESPHome configuration with web_server ota: false +- `test_multipart_ota.py` - Manual test script for OTA functionality +- `test_esp_idf_ota.py` - Automated pytest for OTA functionality + +## Running the Tests + +### 1. Compile and Flash Test Device + +```bash +# Compile the OTA-enabled configuration +esphome compile tests/components/web_server/test_ota.esp32-idf.yaml + +# Flash to device +esphome upload tests/components/web_server/test_ota.esp32-idf.yaml +``` + +### 2. Run Manual Tests + +Once the device is running, you can test OTA functionality: + +```bash +# Test with default settings (creates test firmware) +python tests/components/web_server/test_multipart_ota.py --host + +# Test with real firmware file +python tests/components/web_server/test_multipart_ota.py --host --firmware + +# Skip error condition tests (useful for production devices) +python tests/components/web_server/test_multipart_ota.py --host --skip-error-tests +``` + +### 3. Run Automated Tests + +```bash +# Run pytest suite +pytest tests/component_tests/web_server/test_esp_idf_ota.py +``` + +## What's Being Tested + +1. **Multipart Upload**: Tests that firmware can be uploaded using multipart/form-data +2. **Error Handling**: + - Wrong content type rejection + - Empty file rejection + - Concurrent upload handling +3. **Large Files**: Tests chunked processing of larger firmware files +4. **Boundary Parsing**: Tests various multipart boundary formats + +## Implementation Details + +The ESP-IDF web server uses the `multipart-parser` library to handle multipart uploads. Key components: + +- `MultipartReader` class for parsing multipart data +- Chunked processing to handle large files without excessive memory use +- Integration with ESPHome's OTA component for actual firmware updates + +## Troubleshooting + +1. **Connection Refused**: Make sure the device is on the network and the IP is correct +2. **404 Not Found**: Ensure OTA is enabled in the configuration (`ota: true` in web_server) +3. **Upload Fails**: Check device logs for detailed error messages +4. **Timeout**: Large firmware files may take time, increase timeout if needed \ No newline at end of file From b8579d2040aa387f2a3deb380d9d5ddbb5979a24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 17:39:48 -0500 Subject: [PATCH 34/93] Reduce loop enable/disable log spam by using very verbose level --- esphome/core/application.cpp | 2 +- esphome/core/component.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 328de00640..1599c648e7 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -376,7 +376,7 @@ void Application::enable_pending_loops_() { // Clear the pending flag and enable the loop component->pending_enable_loop_ = false; - ESP_LOGD(TAG, "%s loop enabled from ISR", component->get_component_source()); + ESP_LOGVV(TAG, "%s loop enabled from ISR", component->get_component_source()); component->component_state_ &= ~COMPONENT_STATE_MASK; component->component_state_ |= COMPONENT_STATE_LOOP; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 625a7b2125..8fa63de84e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -149,7 +149,7 @@ void Component::mark_failed() { } void Component::disable_loop() { if ((this->component_state_ & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) { - ESP_LOGD(TAG, "%s loop disabled", this->get_component_source()); + ESP_LOGVV(TAG, "%s loop disabled", this->get_component_source()); this->component_state_ &= ~COMPONENT_STATE_MASK; this->component_state_ |= COMPONENT_STATE_LOOP_DONE; App.disable_component_loop_(this); @@ -157,7 +157,7 @@ void Component::disable_loop() { } void Component::enable_loop() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - ESP_LOGD(TAG, "%s loop enabled", this->get_component_source()); + ESP_LOGVV(TAG, "%s loop enabled", this->get_component_source()); this->component_state_ &= ~COMPONENT_STATE_MASK; this->component_state_ |= COMPONENT_STATE_LOOP; App.enable_component_loop_(this); From f8cb44fb3cf38e8e891e7c7398889542bf9ca523 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 17:54:11 -0500 Subject: [PATCH 35/93] fixes --- esphome/components/web_server/web_server.cpp | 13 +++++++++++ .../web_server_base/web_server_base.cpp | 22 ++++++++++++++++++- .../web_server_idf/web_server_idf.cpp | 9 +++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 927659e621..97ff5f4524 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1929,6 +1929,15 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif +#ifdef USE_ESP_IDF + if (request->url() == "/events") { + // Events are not supported on ESP-IDF yet + // Return a proper response to avoid "uri handler execution failed" warnings + request->send(501, "text/plain", "Server-Sent Events not supported on ESP-IDF"); + return; + } +#endif + #ifdef USE_WEBSERVER_CSS_INCLUDE if (request->url() == "/0.css") { this->handle_css_request(request); @@ -2085,6 +2094,10 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } #endif + + // No matching handler found - send 404 + ESP_LOGD(TAG, "Request for unknown URL: %s", request->url().c_str()); + request->send(404, "text/plain", "Not Found"); } bool WebServer::isRequestHandlerTrivial() const { return false; } diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 631c587391..7445286ae0 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -44,7 +44,17 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { void OTARequestHandler::schedule_ota_reboot_() { ESP_LOGI(TAG, "OTA update successful!"); - this->parent_->set_timeout(100, []() { App.safe_reboot(); }); + this->parent_->set_timeout(100, [this]() { + ESP_LOGI(TAG, "Performing OTA reboot now"); +#ifdef USE_ESP_IDF + // Stop the web server before rebooting to avoid "uri handler execution failed" warnings + if (this->parent_->get_server()) { + ESP_LOGD(TAG, "Stopping web server before reboot"); + this->parent_->get_server()->end(); + } +#endif + App.safe_reboot(); + }); } void OTARequestHandler::ota_init_(const char *filename) { @@ -217,7 +227,17 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { } #endif // USE_ARDUINO #ifdef USE_ESP_IDF + // For ESP-IDF, we use direct send() instead of beginResponse() + // to ensure the response is sent immediately before the reboot. + // + // Note about "uri handler execution failed" warnings: + // During OTA completion, the ESP-IDF HTTP server may log these warnings + // as the system prepares for reboot. They occur because: + // 1. The browser may try to fetch resources (e.g., /events) after OTA completes + // 2. The server is shutting down and can't process new requests + // These warnings are harmless and expected during OTA reboot. if (this->ota_success_) { + ESP_LOGD(TAG, "Sending OTA success response before reboot"); request->send(200, "text/plain", "Update Successful!"); } else { request->send(200, "text/plain", "Update Failed!"); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 102cccf298..424e905c2b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -49,6 +49,9 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; + // Increase stack size for OTA operations - esp_ota_end() needs more stack + // during image validation than the default 4096 bytes + config.stack_size = 6144; if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", @@ -337,7 +340,11 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const this->on_not_found_(request); return ESP_OK; } - return ESP_ERR_NOT_FOUND; + // No handler found - send 404 response + // This prevents "uri handler execution failed" warnings + ESP_LOGD(TAG, "No handler found for URL: %s (method: %d)", request->url().c_str(), request->method()); + request->send(404, "text/plain", "Not Found"); + return ESP_OK; } AsyncWebServerRequest::~AsyncWebServerRequest() { From e4dee935ce17f7cb1acf6e4bb6768c63a53499c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:21:24 -0500 Subject: [PATCH 36/93] Fix thread-safe cleanup of event source connections in ESP-IDF web server --- .../web_server_idf/web_server_idf.cpp | 39 ++++++++++++++----- .../web_server_idf/web_server_idf.h | 3 +- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 90fdf720cd..651bb5d1f5 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -292,21 +292,38 @@ void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { } void AsyncEventSource::loop() { - for (auto *ses : this->sessions_) { - ses->loop(); + // Clean up dead sessions safely + // This follows the ESP-IDF pattern where free_ctx marks resources as dead + // and the main loop handles the actual cleanup to avoid race conditions + auto it = this->sessions_.begin(); + while (it != this->sessions_.end()) { + auto *ses = *it; + // If the session has a dead socket (marked by destroy callback) + if (ses->fd_.load() == 0) { + ESP_LOGD(TAG, "Removing dead event source session"); + it = this->sessions_.erase(it); + delete ses; // NOLINT(cppcoreguidelines-owning-memory) + } else { + ses->loop(); + ++it; + } } } void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { for (auto *ses : this->sessions_) { - ses->try_send_nodefer(message, event, id, reconnect); + if (ses->fd_.load() != 0) { // Skip dead sessions + ses->try_send_nodefer(message, event, id, reconnect); + } } } void AsyncEventSource::deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) { for (auto *ses : this->sessions_) { - ses->deferrable_send_state(source, event_type, message_generator); + if (ses->fd_.load() != 0) { // Skip dead sessions + ses->deferrable_send_state(source, event_type, message_generator); + } } } @@ -331,7 +348,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * req->free_ctx = AsyncEventSourceResponse::destroy; this->hd_ = req->handle; - this->fd_ = httpd_req_to_sockfd(req); + this->fd_.store(httpd_req_to_sockfd(req)); // Configure reconnect timeout and send config // this should always go through since the tcp send buffer is empty on connect @@ -360,8 +377,10 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); - rsp->server_->sessions_.erase(rsp); - delete rsp; // NOLINT(cppcoreguidelines-owning-memory) + ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load()); + // Mark as dead by setting fd to 0 - will be cleaned up in the main loop + rsp->fd_.store(0); + // Note: We don't delete or remove from set here to avoid race conditions } // helper for allowing only unique entries in the queue @@ -401,9 +420,11 @@ void AsyncEventSourceResponse::process_buffer_() { return; } - int bytes_sent = httpd_socket_send(this->hd_, this->fd_, event_buffer_.c_str() + event_bytes_sent_, + int bytes_sent = httpd_socket_send(this->hd_, this->fd_.load(), event_buffer_.c_str() + event_bytes_sent_, event_buffer_.size() - event_bytes_sent_, 0); if (bytes_sent == HTTPD_SOCK_ERR_TIMEOUT || bytes_sent == HTTPD_SOCK_ERR_FAIL) { + // Socket error - just return, the connection will be closed by httpd + // and our destroy callback will be called return; } event_bytes_sent_ += bytes_sent; @@ -423,7 +444,7 @@ void AsyncEventSourceResponse::loop() { bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { - if (this->fd_ == 0) { + if (this->fd_.load() == 0) { return false; } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 8dafdf11ef..7547117224 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include +#include #include #include #include @@ -271,7 +272,7 @@ class AsyncEventSourceResponse { static void destroy(void *p); AsyncEventSource *server_; httpd_handle_t hd_{}; - int fd_{}; + std::atomic fd_{}; std::vector deferred_queue_; esphome::web_server::WebServer *web_server_; std::unique_ptr entities_iterator_; From 0005aad5b5094a281f8b8115164b897e32b1cd01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:30:00 -0500 Subject: [PATCH 37/93] cleanup --- .../web_server_base/web_server_base.cpp | 8 ++-- .../web_server_idf/multipart_parser_utils.cpp | 2 +- .../web_server_idf/multipart_reader.cpp | 10 ++-- .../web_server_idf/web_server_idf.cpp | 46 +++++-------------- 4 files changed, 21 insertions(+), 45 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 7445286ae0..0253812e70 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -160,9 +160,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // Log first chunk of data received by OTA handler if (this->ota_read_length_ == 0 && len >= 8) { - ESP_LOGD(TAG, "First data received by OTA handler: %02x %02x %02x %02x %02x %02x %02x %02x", data[0], data[1], + ESP_LOGV(TAG, "First data received by OTA handler: %02x %02x %02x %02x %02x %02x %02x %02x", data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]); - ESP_LOGD(TAG, "Data pointer in OTA handler: %p, len: %zu, index: %zu", data, len, index); + ESP_LOGV(TAG, "Data pointer in OTA handler: %p, len: %zu, index: %zu", data, len, index); } // Feed watchdog and yield periodically to prevent timeout during OTA @@ -214,7 +214,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_OTA - ESP_LOGD(TAG, "OTA handleRequest called"); + ESP_LOGV(TAG, "OTA handleRequest called"); AsyncWebServerResponse *response; #ifdef USE_ARDUINO if (!Update.hasError()) { @@ -237,7 +237,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { // 2. The server is shutting down and can't process new requests // These warnings are harmless and expected during OTA reboot. if (this->ota_success_) { - ESP_LOGD(TAG, "Sending OTA success response before reboot"); + ESP_LOGV(TAG, "Sending OTA success response before reboot"); request->send(200, "text/plain", "Update Successful!"); } else { request->send(200, "text/plain", "Update Failed!"); diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart_parser_utils.cpp index bc548492eb..66ba570b85 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.cpp +++ b/esphome/components/web_server_idf/multipart_parser_utils.cpp @@ -184,7 +184,7 @@ bool parse_multipart_boundary(const char *content_type, const char **boundary_st *boundary_start = start; // Debug log the extracted boundary - ESP_LOGD("multipart_utils", "Extracted boundary: '%.*s' (len: %zu)", (int) *boundary_len, start, *boundary_len); + ESP_LOGV("multipart_utils", "Extracted boundary: '%.*s' (len: %zu)", (int) *boundary_len, start, *boundary_len); return true; } diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 2f3ea9190d..c05927c5fe 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -48,15 +48,15 @@ size_t MultipartReader::parse(const char *data, size_t len) { size_t parsed = multipart_parser_execute(parser_, data, len); if (parsed != len) { - ESP_LOGD(TAG, "Parser consumed %zu of %zu bytes", parsed, len); + ESP_LOGW(TAG, "Parser consumed %zu of %zu bytes - possible error", parsed, len); // Log the data around the error point if (parsed < len && parsed < 32) { - ESP_LOGD(TAG, "Data at error point (offset %zu): %02x %02x %02x %02x", parsed, + ESP_LOGV(TAG, "Data at error point (offset %zu): %02x %02x %02x %02x", parsed, parsed > 0 ? (uint8_t) data[parsed - 1] : 0, (uint8_t) data[parsed], parsed + 1 < len ? (uint8_t) data[parsed + 1] : 0, parsed + 2 < len ? (uint8_t) data[parsed + 2] : 0); // Log what we have vs what parser expects - ESP_LOGD(TAG, "Parser error at position %zu: got '%c' (0x%02x)", parsed, data[parsed], (uint8_t) data[parsed]); + ESP_LOGV(TAG, "Parser error at position %zu: got '%c' (0x%02x)", parsed, data[parsed], (uint8_t) data[parsed]); } } @@ -107,7 +107,7 @@ int MultipartReader::on_headers_complete(multipart_parser *parser) { reader->current_header_field_.clear(); reader->current_header_value_.clear(); - ESP_LOGD(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", + ESP_LOGV(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(), reader->current_part_.content_type.c_str()); @@ -131,7 +131,7 @@ int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size // later use as the buffer will be overwritten. // Log first data bytes from multipart parser if (!reader->first_data_logged_ && length >= 8) { - ESP_LOGD(TAG, "First part data from parser: %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) at[0], + ESP_LOGV(TAG, "First part data from parser: %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) at[0], (uint8_t) at[1], (uint8_t) at[2], (uint8_t) at[3], (uint8_t) at[4], (uint8_t) at[5], (uint8_t) at[6], (uint8_t) at[7]); reader->first_data_logged_ = true; diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bde86925fc..b5f53897a1 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -96,7 +96,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { if (parse_multipart_boundary(ct.c_str(), &boundary_start, &boundary_len)) { boundary.assign(boundary_start, boundary_len); is_multipart = true; - ESP_LOGD(TAG, "Multipart upload detected, boundary: '%s' (len: %zu)", boundary.c_str(), boundary_len); + ESP_LOGV(TAG, "Multipart upload detected, boundary: '%s' (len: %zu)", boundary.c_str(), boundary_len); } else if (!is_form_urlencoded(ct.c_str())) { ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); // fallback to get handler to support backward compatibility @@ -143,7 +143,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { // Handle multipart upload using the multipart-parser library // The multipart data starts with "--" + boundary, so we need to prepend it std::string full_boundary = "--" + boundary; - ESP_LOGV(TAG, "Initializing multipart reader with full boundary: '%s'", full_boundary.c_str()); + ESP_LOGVV(TAG, "Initializing multipart reader with full boundary: '%s'", full_boundary.c_str()); MultipartReader reader(full_boundary); static constexpr size_t CHUNK_SIZE = 1024; // IMPORTANT: chunk_buf is reused for each chunk read from the socket. @@ -172,20 +172,12 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { if (current_filename.empty()) { // First time we see data for this file current_filename = reader.get_current_part().filename; - ESP_LOGD(TAG, "Processing file part: '%s'", current_filename.c_str()); - } - - // Log first few bytes of firmware data (only once) - static bool firmware_data_logged = false; - if (!firmware_data_logged && len >= 8) { - ESP_LOGD(TAG, "First firmware bytes from callback: %02x %02x %02x %02x %02x %02x %02x %02x", data[0], data[1], - data[2], data[3], data[4], data[5], data[6], data[7]); - firmware_data_logged = true; + ESP_LOGV(TAG, "Processing file part: '%s'", current_filename.c_str()); } if (!file_started) { // Initialize the upload with index=0 - ESP_LOGD(TAG, "Starting upload for: '%s'", current_filename.c_str()); + ESP_LOGV(TAG, "Starting upload for: '%s'", current_filename.c_str()); found_handler->handleUpload(&req, current_filename, 0, nullptr, 0, false); file_started = true; upload_started = true; @@ -201,7 +193,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { reader.set_part_complete_callback([&]() { if (!current_filename.empty() && upload_started) { - ESP_LOGD(TAG, "Part complete callback called for: '%s'", current_filename.c_str()); + ESP_LOGV(TAG, "Part complete callback called for: '%s'", current_filename.c_str()); // Signal end of this part - final=true signals completion found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); current_filename.clear(); @@ -243,30 +235,14 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { static size_t bytes_logged = 0; bytes_logged += recv_len; if (bytes_logged > 100000) { - ESP_LOGD(TAG, "OTA progress: %zu bytes remaining", remaining); + ESP_LOGV(TAG, "OTA progress: %zu bytes remaining", remaining); bytes_logged = 0; } // Log first few bytes for debugging if (total_len == remaining) { - ESP_LOGD(TAG, "First chunk data (hex): %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) chunk_buf[0], - (uint8_t) chunk_buf[1], (uint8_t) chunk_buf[2], (uint8_t) chunk_buf[3], (uint8_t) chunk_buf[4], - (uint8_t) chunk_buf[5], (uint8_t) chunk_buf[6], (uint8_t) chunk_buf[7]); - ESP_LOGD(TAG, "First chunk data (ascii): %.8s", chunk_buf.get()); - ESP_LOGD(TAG, "Expected boundary start: %.8s", full_boundary.c_str()); - - // Log more of the first chunk to see the headers - ESP_LOGD(TAG, "First 256 bytes of upload:"); - for (int i = 0; i < std::min(recv_len, 256); i += 16) { - char hex_buf[50]; - char ascii_buf[17]; - int n = std::min(16, recv_len - i); - for (int j = 0; j < n; j++) { - sprintf(hex_buf + j * 3, "%02x ", (uint8_t) chunk_buf[i + j]); - ascii_buf[j] = isprint(chunk_buf[i + j]) ? chunk_buf[i + j] : '.'; - } - ascii_buf[n] = '\0'; - ESP_LOGD(TAG, "%04x: %-48s %s", i, hex_buf, ascii_buf); - } + ESP_LOGVV(TAG, "First chunk data (hex): %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) chunk_buf[0], + (uint8_t) chunk_buf[1], (uint8_t) chunk_buf[2], (uint8_t) chunk_buf[3], (uint8_t) chunk_buf[4], + (uint8_t) chunk_buf[5], (uint8_t) chunk_buf[6], (uint8_t) chunk_buf[7]); } size_t parsed = reader.parse(chunk_buf.get(), recv_len); @@ -289,9 +265,9 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } // Let handler send response - ESP_LOGD(TAG, "Calling handleRequest for OTA response"); + ESP_LOGV(TAG, "Calling handleRequest for OTA response"); found_handler->handleRequest(&req); - ESP_LOGD(TAG, "handleRequest completed"); + ESP_LOGV(TAG, "handleRequest completed"); return ESP_OK; } #endif // USE_WEBSERVER_OTA From 7fe8cdaa349b755e76f300a45a38d0863307618a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:37:48 -0500 Subject: [PATCH 38/93] remove cruft --- esphome/components/web_server/web_server.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 97ff5f4524..2cd4d322a7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1929,15 +1929,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif -#ifdef USE_ESP_IDF - if (request->url() == "/events") { - // Events are not supported on ESP-IDF yet - // Return a proper response to avoid "uri handler execution failed" warnings - request->send(501, "text/plain", "Server-Sent Events not supported on ESP-IDF"); - return; - } -#endif - #ifdef USE_WEBSERVER_CSS_INCLUDE if (request->url() == "/0.css") { this->handle_css_request(request); From c655c4e10639d28677311110e6ca1b9a78f64881 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:39:17 -0500 Subject: [PATCH 39/93] remove cruft --- esphome/components/web_server_base/web_server_base.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 0253812e70..2f545c8d3f 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -46,13 +46,6 @@ void OTARequestHandler::schedule_ota_reboot_() { ESP_LOGI(TAG, "OTA update successful!"); this->parent_->set_timeout(100, [this]() { ESP_LOGI(TAG, "Performing OTA reboot now"); -#ifdef USE_ESP_IDF - // Stop the web server before rebooting to avoid "uri handler execution failed" warnings - if (this->parent_->get_server()) { - ESP_LOGD(TAG, "Stopping web server before reboot"); - this->parent_->get_server()->end(); - } -#endif App.safe_reboot(); }); } From e3a3305adb1a067cad1aecb6d000afe2824c00d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:44:02 -0500 Subject: [PATCH 40/93] delete --- .../web_server/test_multipart_ota.py | 182 ------------------ .../components/web_server/test_ota_readme.md | 70 ------- 2 files changed, 252 deletions(-) delete mode 100755 tests/components/web_server/test_multipart_ota.py delete mode 100644 tests/components/web_server/test_ota_readme.md diff --git a/tests/components/web_server/test_multipart_ota.py b/tests/components/web_server/test_multipart_ota.py deleted file mode 100755 index 84e3264e1b..0000000000 --- a/tests/components/web_server/test_multipart_ota.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for ESP-IDF web server multipart OTA upload functionality. -This script can be run manually to test OTA uploads to a running device. -""" - -import argparse -from pathlib import Path -import sys -import time - -import requests - - -def test_multipart_ota_upload(host, port, firmware_path): - """Test OTA firmware upload using multipart/form-data""" - base_url = f"http://{host}:{port}" - - print(f"Testing OTA upload to {base_url}") - - # First check if server is reachable - try: - resp = requests.get(f"{base_url}/", timeout=5) - if resp.status_code != 200: - print(f"Error: Server returned status {resp.status_code}") - return False - print("✓ Server is reachable") - except requests.exceptions.RequestException as e: - print(f"Error: Cannot reach server - {e}") - return False - - # Check if firmware file exists - if not Path(firmware_path).exists(): - print(f"Error: Firmware file not found: {firmware_path}") - return False - - # Prepare multipart upload - print(f"Uploading firmware: {firmware_path}") - print(f"File size: {Path(firmware_path).stat().st_size} bytes") - - try: - with open(firmware_path, "rb") as f: - files = {"firmware": ("firmware.bin", f, "application/octet-stream")} - - # Send OTA update request - resp = requests.post(f"{base_url}/ota/upload", files=files, timeout=60) - - if resp.status_code in [200, 201, 204]: - print(f"✓ OTA upload successful (status: {resp.status_code})") - if resp.text: - print(f"Response: {resp.text}") - return True - else: - print(f"✗ OTA upload failed with status {resp.status_code}") - print(f"Response: {resp.text}") - return False - - except requests.exceptions.RequestException as e: - print(f"Error during upload: {e}") - return False - - -def test_ota_with_wrong_content_type(host, port): - """Test that OTA upload fails gracefully with wrong content type""" - base_url = f"http://{host}:{port}" - - print("\nTesting OTA with wrong content type...") - - try: - # Send plain text instead of multipart - headers = {"Content-Type": "text/plain"} - resp = requests.post( - f"{base_url}/ota/upload", - data="This is not a firmware file", - headers=headers, - timeout=10, - ) - - if resp.status_code >= 400: - print( - f"✓ Server correctly rejected wrong content type (status: {resp.status_code})" - ) - return True - else: - print(f"✗ Server accepted wrong content type (status: {resp.status_code})") - return False - - except requests.exceptions.RequestException as e: - print(f"Error: {e}") - return False - - -def test_ota_with_empty_file(host, port): - """Test that OTA upload fails gracefully with empty file""" - base_url = f"http://{host}:{port}" - - print("\nTesting OTA with empty file...") - - try: - # Send empty file - files = {"firmware": ("empty.bin", b"", "application/octet-stream")} - resp = requests.post(f"{base_url}/ota/upload", files=files, timeout=10) - - if resp.status_code >= 400: - print( - f"✓ Server correctly rejected empty file (status: {resp.status_code})" - ) - return True - else: - print(f"✗ Server accepted empty file (status: {resp.status_code})") - return False - - except requests.exceptions.RequestException as e: - print(f"Error: {e}") - return False - - -def create_test_firmware(size_kb=10): - """Create a dummy firmware file for testing""" - import tempfile - - with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: - # ESP32 firmware magic bytes - f.write(b"\xe9\x08\x02\x20") - # Add padding - f.write(b"\x00" * (size_kb * 1024 - 4)) - return f.name - - -def main(): - parser = argparse.ArgumentParser( - description="Test ESP-IDF web server OTA functionality" - ) - parser.add_argument("--host", default="localhost", help="Device hostname or IP") - parser.add_argument("--port", type=int, default=8080, help="Web server port") - parser.add_argument( - "--firmware", help="Path to firmware file (if not specified, creates test file)" - ) - parser.add_argument( - "--skip-error-tests", action="store_true", help="Skip error condition tests" - ) - - args = parser.parse_args() - - # Create test firmware if not specified - firmware_path = args.firmware - if not firmware_path: - print("Creating test firmware file...") - firmware_path = create_test_firmware(100) # 100KB test file - print(f"Created test firmware: {firmware_path}") - - all_passed = True - - # Test successful OTA upload - if not test_multipart_ota_upload(args.host, args.port, firmware_path): - all_passed = False - - # Test error conditions - if not args.skip_error_tests: - time.sleep(1) # Small delay between tests - - if not test_ota_with_wrong_content_type(args.host, args.port): - all_passed = False - - time.sleep(1) - - if not test_ota_with_empty_file(args.host, args.port): - all_passed = False - - # Clean up test firmware if we created it - if not args.firmware: - import os - - os.unlink(firmware_path) - print("\nCleaned up test firmware") - - print(f"\n{'All tests passed!' if all_passed else 'Some tests failed!'}") - return 0 if all_passed else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/components/web_server/test_ota_readme.md b/tests/components/web_server/test_ota_readme.md deleted file mode 100644 index bb93db6e06..0000000000 --- a/tests/components/web_server/test_ota_readme.md +++ /dev/null @@ -1,70 +0,0 @@ -# Testing ESP-IDF Web Server OTA Functionality - -This directory contains tests for the ESP-IDF web server OTA (Over-The-Air) update functionality using multipart form uploads. - -## Test Files - -- `test_ota.esp32-idf.yaml` - ESPHome configuration with OTA enabled for ESP-IDF -- `test_no_ota.esp32-idf.yaml` - ESPHome configuration with OTA disabled -- `test_ota_disabled.esp32-idf.yaml` - ESPHome configuration with web_server ota: false -- `test_multipart_ota.py` - Manual test script for OTA functionality -- `test_esp_idf_ota.py` - Automated pytest for OTA functionality - -## Running the Tests - -### 1. Compile and Flash Test Device - -```bash -# Compile the OTA-enabled configuration -esphome compile tests/components/web_server/test_ota.esp32-idf.yaml - -# Flash to device -esphome upload tests/components/web_server/test_ota.esp32-idf.yaml -``` - -### 2. Run Manual Tests - -Once the device is running, you can test OTA functionality: - -```bash -# Test with default settings (creates test firmware) -python tests/components/web_server/test_multipart_ota.py --host - -# Test with real firmware file -python tests/components/web_server/test_multipart_ota.py --host --firmware - -# Skip error condition tests (useful for production devices) -python tests/components/web_server/test_multipart_ota.py --host --skip-error-tests -``` - -### 3. Run Automated Tests - -```bash -# Run pytest suite -pytest tests/component_tests/web_server/test_esp_idf_ota.py -``` - -## What's Being Tested - -1. **Multipart Upload**: Tests that firmware can be uploaded using multipart/form-data -2. **Error Handling**: - - Wrong content type rejection - - Empty file rejection - - Concurrent upload handling -3. **Large Files**: Tests chunked processing of larger firmware files -4. **Boundary Parsing**: Tests various multipart boundary formats - -## Implementation Details - -The ESP-IDF web server uses the `multipart-parser` library to handle multipart uploads. Key components: - -- `MultipartReader` class for parsing multipart data -- Chunked processing to handle large files without excessive memory use -- Integration with ESPHome's OTA component for actual firmware updates - -## Troubleshooting - -1. **Connection Refused**: Make sure the device is on the network and the IP is correct -2. **404 Not Found**: Ensure OTA is enabled in the configuration (`ota: true` in web_server) -3. **Upload Fails**: Check device logs for detailed error messages -4. **Timeout**: Large firmware files may take time, increase timeout if needed \ No newline at end of file From b25f272d721882331fbfca0cb0cf21ba9c0da8c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:44:14 -0500 Subject: [PATCH 41/93] lint --- esphome/components/web_server_idf/multipart_parser_utils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart_parser_utils.cpp index 66ba570b85..896b459dba 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.cpp +++ b/esphome/components/web_server_idf/multipart_parser_utils.cpp @@ -211,4 +211,4 @@ std::string str_trim(const std::string &str) { } // namespace web_server_idf } // namespace esphome #endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF \ No newline at end of file +#endif // USE_ESP_IDF From bc63d246c8b8bf429ea151b1c3e017ac1e83a065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:46:15 -0500 Subject: [PATCH 42/93] cleanup --- esphome/components/web_server_base/web_server_base.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 2f545c8d3f..6be8b6e920 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -151,13 +151,6 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin if (len > 0) { auto *backend = static_cast(this->ota_backend_); - // Log first chunk of data received by OTA handler - if (this->ota_read_length_ == 0 && len >= 8) { - ESP_LOGV(TAG, "First data received by OTA handler: %02x %02x %02x %02x %02x %02x %02x %02x", data[0], data[1], - data[2], data[3], data[4], data[5], data[6], data[7]); - ESP_LOGV(TAG, "Data pointer in OTA handler: %p, len: %zu, index: %zu", data, len, index); - } - // Feed watchdog and yield periodically to prevent timeout during OTA // Flash writes can be slow, especially for large chunks static uint32_t last_ota_yield = 0; From 92f6f3ac0ddeeb3e014e5dd7f7d782fd3a6f900f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:48:30 -0500 Subject: [PATCH 43/93] cleanup --- .../web_server_base/web_server_base.cpp | 15 --------------- .../components/web_server_idf/web_server_idf.cpp | 16 ---------------- 2 files changed, 31 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 6be8b6e920..b48cda7e39 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -151,21 +151,6 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin if (len > 0) { auto *backend = static_cast(this->ota_backend_); - // Feed watchdog and yield periodically to prevent timeout during OTA - // Flash writes can be slow, especially for large chunks - static uint32_t last_ota_yield = 0; - static uint32_t ota_chunks_written = 0; - uint32_t now = millis(); - ota_chunks_written++; - - // Yield more frequently during OTA - every 25ms or every 2 chunks - if (now - last_ota_yield > 25 || ota_chunks_written >= 2) { - // Don't log during yield - logging itself can cause delays - vTaskDelay(2); // Let other tasks run for 2 ticks - last_ota_yield = now; - ota_chunks_written = 0; - } - auto result = backend->write(data, len); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", result); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index b5f53897a1..617e9a3747 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -202,12 +202,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } }); - // Track time to yield periodically - uint32_t last_yield = millis(); - static constexpr uint32_t YIELD_INTERVAL_MS = 50; // Yield every 50ms - uint32_t chunks_processed = 0; - static constexpr uint32_t CHUNKS_PER_YIELD = 5; // Also yield every 5 chunks - while (remaining > 0) { size_t to_read = std::min(remaining, CHUNK_SIZE); int recv_len = httpd_req_recv(r, chunk_buf.get(), to_read); @@ -221,16 +215,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_FAIL; } - // Yield periodically to prevent watchdog timeout - chunks_processed++; - uint32_t now = millis(); - if (now - last_yield > YIELD_INTERVAL_MS || chunks_processed >= CHUNKS_PER_YIELD) { - // Don't log during yield - logging itself can cause delays - vTaskDelay(2); // Yield for 2 ticks to give more time to other tasks - last_yield = now; - chunks_processed = 0; - } - // Log received vs requested - only log every 100KB to reduce overhead static size_t bytes_logged = 0; bytes_logged += recv_len; From 4d460d4bc3536da699085dcc6041ffe9cdb15ac2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:51:35 -0500 Subject: [PATCH 44/93] cleanup --- .../components/web_server_idf/multipart_parser_utils.cpp | 6 ++---- esphome/components/web_server_idf/multipart_parser_utils.h | 6 ++---- esphome/components/web_server_idf/multipart_reader.cpp | 6 ++---- esphome/components/web_server_idf/multipart_reader.h | 6 ++---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart_parser_utils.cpp index 896b459dba..de1906a0a6 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.cpp +++ b/esphome/components/web_server_idf/multipart_parser_utils.cpp @@ -1,6 +1,5 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include "multipart_parser_utils.h" #include "esphome/core/log.h" @@ -210,5 +209,4 @@ std::string str_trim(const std::string &str) { } // namespace web_server_idf } // namespace esphome -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index d58232a067..1829a17b35 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include #include @@ -42,5 +41,4 @@ std::string str_trim(const std::string &str); } // namespace web_server_idf } // namespace esphome -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index c05927c5fe..624523f7a0 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -1,6 +1,5 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include "multipart_reader.h" #include "multipart_parser_utils.h" #include "esphome/core/log.h" @@ -163,5 +162,4 @@ int MultipartReader::on_part_data_end(multipart_parser *parser) { } // namespace web_server_idf } // namespace esphome -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index 71607cc99b..ca46a9e88b 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF -#ifdef USE_WEBSERVER_OTA +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include #include @@ -70,5 +69,4 @@ class MultipartReader { } // namespace web_server_idf } // namespace esphome -#endif // USE_WEBSERVER_OTA -#endif // USE_ESP_IDF +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) From bcbf0f0e2661391208e87f3a8268fbbfa1f0f68e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:53:43 -0500 Subject: [PATCH 45/93] cleanup --- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server_base/web_server_base.cpp | 5 ----- esphome/components/web_server_idf/web_server_idf.cpp | 2 -- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 2cd4d322a7..c77edb2bd5 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2087,7 +2087,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { #endif // No matching handler found - send 404 - ESP_LOGD(TAG, "Request for unknown URL: %s", request->url().c_str()); + ESP_LOGV(TAG, "Request for unknown URL: %s", request->url().c_str()); request->send(404, "text/plain", "Not Found"); } diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index b48cda7e39..765fcbc5bc 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -4,11 +4,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP_IDF -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#endif - #ifdef USE_ARDUINO #include #if defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 617e9a3747..f734b118d2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -7,8 +7,6 @@ #include "esphome/core/log.h" #include "esp_tls_crypto.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" #include "utils.h" #include "web_server_idf.h" From af2f5b734893d9cea9dc4b68586e7d2a28810ed7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:54:14 -0500 Subject: [PATCH 46/93] cleanup --- esphome/components/web_server_base/web_server_base.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 765fcbc5bc..cad3ce5386 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -203,7 +203,6 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { // 2. The server is shutting down and can't process new requests // These warnings are harmless and expected during OTA reboot. if (this->ota_success_) { - ESP_LOGV(TAG, "Sending OTA success response before reboot"); request->send(200, "text/plain", "Update Successful!"); } else { request->send(200, "text/plain", "Update Failed!"); From 18844e15dc70104588562897f745d38a62662719 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:54:48 -0500 Subject: [PATCH 47/93] cleanup --- .../components/web_server_base/web_server_base.cpp | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index cad3ce5386..11ceeeb17a 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -195,18 +195,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #ifdef USE_ESP_IDF // For ESP-IDF, we use direct send() instead of beginResponse() // to ensure the response is sent immediately before the reboot. - // - // Note about "uri handler execution failed" warnings: - // During OTA completion, the ESP-IDF HTTP server may log these warnings - // as the system prepares for reboot. They occur because: - // 1. The browser may try to fetch resources (e.g., /events) after OTA completes - // 2. The server is shutting down and can't process new requests - // These warnings are harmless and expected during OTA reboot. - if (this->ota_success_) { - request->send(200, "text/plain", "Update Successful!"); - } else { - request->send(200, "text/plain", "Update Failed!"); - } + request->send(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!"); return; #endif // USE_ESP_IDF response->addHeader("Connection", "close"); From c420bf5f4f9d1b5c07698d44584f2934213ea21d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:55:46 -0500 Subject: [PATCH 48/93] cleanup --- esphome/components/web_server_base/web_server_base.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 11ceeeb17a..9aab44c9ed 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -136,7 +136,6 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // Store the backend pointer this->ota_backend_ = backend.release(); this->ota_started_ = true; - this->ota_success_ = false; // Will be set to true only on successful completion } else if (!this->ota_started_ || !this->ota_backend_) { // Begin failed or was aborted return; From 5205ff5c43d146b978e1434fc3cb181e3d58b1fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 18:59:09 -0500 Subject: [PATCH 49/93] cleanup --- esphome/components/ota/ota_backend_esp_idf.cpp | 1 - esphome/components/ota/ota_backend_esp_idf.h | 3 +-- esphome/components/web_server_base/web_server_base.cpp | 5 +++-- esphome/components/web_server_base/web_server_base.h | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ee0966d807..fbc5c09a39 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -19,7 +19,6 @@ std::unique_ptr make_ota_backend() { return make_uniquemd5_set_ = false; - memset(this->expected_bin_md5_, 0, sizeof(this->expected_bin_md5_)); this->partition_ = esp_ota_get_next_update_partition(nullptr); if (this->partition_ == nullptr) { diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index e810cd1f9c..deed354499 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -6,14 +6,13 @@ #include "esphome/core/defines.h" #include -#include namespace esphome { namespace ota { class IDFOTABackend : public OTABackend { public: - IDFOTABackend() : md5_set_(false) { memset(expected_bin_md5_, 0, sizeof(expected_bin_md5_)); } + IDFOTABackend() : md5_set_(false), expected_bin_md5_{} {} OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; OTAResponseTypes write(uint8_t *data, size_t len) override; diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 9aab44c9ed..1db6dc43e8 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -129,14 +129,15 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin auto result = backend->begin(0); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", result); - this->ota_success_ = false; return; } // Store the backend pointer this->ota_backend_ = backend.release(); this->ota_started_ = true; - } else if (!this->ota_started_ || !this->ota_backend_) { + } + + if (!this->ota_started_ || !this->ota_backend_) { // Begin failed or was aborted return; } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index ee804674e1..d6be110582 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -157,7 +157,7 @@ class OTARequestHandler : public AsyncWebHandler { private: #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - void *ota_backend_{nullptr}; // Actually ota::OTABackend*, stored as void* to avoid incomplete type issues + void *ota_backend_{nullptr}; bool ota_started_{false}; bool ota_success_{false}; #endif From 596a28e1fbfebe771a02585022a680185a4ad028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:00:07 -0500 Subject: [PATCH 50/93] cleanup --- esphome/components/ota/ota_backend_esp_idf.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index fbc5c09a39..2952cc3b12 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -6,6 +6,7 @@ #include #include +#include #if ESP_IDF_VERSION_MAJOR >= 5 #include From 9097d646ca057ffe436577870eaedd777a111f5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:03:48 -0500 Subject: [PATCH 51/93] cleanup --- .../components/web_server_idf/multipart_reader.cpp | 13 +------------ .../components/web_server_idf/multipart_reader.h | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 624523f7a0..53c207ded0 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -11,7 +11,7 @@ namespace web_server_idf { static const char *const TAG = "multipart_reader"; -MultipartReader::MultipartReader(const std::string &boundary) : first_data_logged_(false) { +MultipartReader::MultipartReader(const std::string &boundary) { // Initialize settings with callbacks memset(&settings_, 0, sizeof(settings_)); settings_.on_header_field = on_header_field; @@ -128,14 +128,6 @@ int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size // This data is only valid during this callback. The callback handler MUST // process or copy the data immediately - it cannot store the pointer for // later use as the buffer will be overwritten. - // Log first data bytes from multipart parser - if (!reader->first_data_logged_ && length >= 8) { - ESP_LOGV(TAG, "First part data from parser: %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) at[0], - (uint8_t) at[1], (uint8_t) at[2], (uint8_t) at[3], (uint8_t) at[4], (uint8_t) at[5], (uint8_t) at[6], - (uint8_t) at[7]); - reader->first_data_logged_ = true; - } - reader->data_callback_(reinterpret_cast(at), length); } @@ -154,9 +146,6 @@ int MultipartReader::on_part_data_end(multipart_parser *parser) { // Clear part info for next part reader->current_part_ = Part{}; - // Reset first_data flag for next upload - reader->first_data_logged_ = false; - return 0; } diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index ca46a9e88b..563e90e3cf 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -62,7 +62,6 @@ class MultipartReader { PartCompleteCallback part_complete_callback_; bool in_headers_{false}; - bool first_data_logged_{false}; void process_header_(); }; From d0ac5388d9317047cb01650a5f5f618db4d33c8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:03:54 -0500 Subject: [PATCH 52/93] cleanup --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index f734b118d2..39caddcad1 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -78,7 +78,7 @@ void AsyncWebServer::begin() { } esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { - ESP_LOGD(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); + ESP_LOGVV(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); auto content_type = request_get_header(r, "Content-Type"); #ifdef USE_WEBSERVER_OTA From 93b6b9835c7d99f9741c4505356260a0630db737 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:04:54 -0500 Subject: [PATCH 53/93] cleanup --- .../web_server_idf/web_server_idf.cpp | 14 -- .../web_server/test_esp_idf_ota.py | 236 ------------------ 2 files changed, 250 deletions(-) delete mode 100644 tests/component_tests/web_server/test_esp_idf_ota.py diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 39caddcad1..4322255589 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -213,20 +213,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return ESP_FAIL; } - // Log received vs requested - only log every 100KB to reduce overhead - static size_t bytes_logged = 0; - bytes_logged += recv_len; - if (bytes_logged > 100000) { - ESP_LOGV(TAG, "OTA progress: %zu bytes remaining", remaining); - bytes_logged = 0; - } - // Log first few bytes for debugging - if (total_len == remaining) { - ESP_LOGVV(TAG, "First chunk data (hex): %02x %02x %02x %02x %02x %02x %02x %02x", (uint8_t) chunk_buf[0], - (uint8_t) chunk_buf[1], (uint8_t) chunk_buf[2], (uint8_t) chunk_buf[3], (uint8_t) chunk_buf[4], - (uint8_t) chunk_buf[5], (uint8_t) chunk_buf[6], (uint8_t) chunk_buf[7]); - } - size_t parsed = reader.parse(chunk_buf.get(), recv_len); if (parsed != recv_len) { ESP_LOGW(TAG, "Multipart parser error at byte %zu (parsed %zu of %d bytes)", total_len - remaining + parsed, diff --git a/tests/component_tests/web_server/test_esp_idf_ota.py b/tests/component_tests/web_server/test_esp_idf_ota.py deleted file mode 100644 index f733017440..0000000000 --- a/tests/component_tests/web_server/test_esp_idf_ota.py +++ /dev/null @@ -1,236 +0,0 @@ -import asyncio -import os -import tempfile - -import aiohttp -import pytest - - -@pytest.fixture -async def web_server_fixture(event_loop): - """Start the test device with web server""" - # This would be replaced with actual device setup in a real test environment - # For now, we'll assume the device is running at a specific address - base_url = "http://localhost:8080" - - # Wait a bit for server to be ready - await asyncio.sleep(2) - - yield base_url - - -async def create_test_firmware(): - """Create a dummy firmware file for testing""" - with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: - # Write some dummy data that looks like a firmware file - # ESP32 firmware files typically start with these magic bytes - f.write(b"\xe9\x08\x02\x20") # ESP32 magic bytes - # Add some padding to make it look like a real firmware - f.write(b"\x00" * 1024) # 1KB of zeros - f.write(b"TEST_FIRMWARE_CONTENT") - f.write(b"\x00" * 1024) # More padding - return f.name - - -@pytest.mark.asyncio -async def test_ota_upload_multipart(web_server_fixture): - """Test OTA firmware upload using multipart/form-data""" - base_url = web_server_fixture - firmware_path = await create_test_firmware() - - try: - # Create multipart form data - async with aiohttp.ClientSession() as session: - # First, check if OTA endpoint is available - async with session.get(f"{base_url}/") as resp: - assert resp.status == 200 - content = await resp.text() - assert "ota" in content or "OTA" in content - - # Prepare multipart upload - with open(firmware_path, "rb") as f: - data = aiohttp.FormData() - data.add_field( - "firmware", - f, - filename="firmware.bin", - content_type="application/octet-stream", - ) - - # Send OTA update request - async with session.post(f"{base_url}/ota/upload", data=data) as resp: - assert resp.status in [200, 201, 204], ( - f"OTA upload failed with status {resp.status}" - ) - - # Check response - if resp.status == 200: - response_text = await resp.text() - # The response might be JSON or plain text depending on implementation - assert ( - "success" in response_text.lower() - or "ok" in response_text.lower() - ) - - finally: - # Clean up - os.unlink(firmware_path) - - -@pytest.mark.asyncio -async def test_ota_upload_wrong_content_type(web_server_fixture): - """Test that OTA upload fails with wrong content type""" - base_url = web_server_fixture - - async with aiohttp.ClientSession() as session: - # Try to upload with wrong content type - data = b"not a firmware file" - headers = {"Content-Type": "text/plain"} - - async with session.post( - f"{base_url}/ota/upload", data=data, headers=headers - ) as resp: - # Should fail with bad request or similar - assert resp.status >= 400, f"Expected error status, got {resp.status}" - - -@pytest.mark.asyncio -async def test_ota_upload_empty_file(web_server_fixture): - """Test that OTA upload fails with empty file""" - base_url = web_server_fixture - - async with aiohttp.ClientSession() as session: - # Create empty multipart upload - data = aiohttp.FormData() - data.add_field( - "firmware", - b"", - filename="empty.bin", - content_type="application/octet-stream", - ) - - async with session.post(f"{base_url}/ota/upload", data=data) as resp: - # Should fail with bad request - assert resp.status >= 400, ( - f"Expected error status for empty file, got {resp.status}" - ) - - -@pytest.mark.asyncio -async def test_ota_multipart_boundary_parsing(web_server_fixture): - """Test multipart boundary parsing edge cases""" - base_url = web_server_fixture - firmware_path = await create_test_firmware() - - try: - async with aiohttp.ClientSession() as session: - # Test with custom boundary - with open(firmware_path, "rb") as f: - # Create multipart manually with specific boundary - boundary = "----WebKitFormBoundaryCustomTest123" - body = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="firmware"; filename="test.bin"\r\n' - f"Content-Type: application/octet-stream\r\n" - f"\r\n" - ).encode() - body += f.read() - body += f"\r\n--{boundary}--\r\n".encode() - - headers = { - "Content-Type": f"multipart/form-data; boundary={boundary}", - "Content-Length": str(len(body)), - } - - async with session.post( - f"{base_url}/ota/upload", data=body, headers=headers - ) as resp: - assert resp.status in [200, 201, 204], ( - f"Custom boundary upload failed with status {resp.status}" - ) - - finally: - os.unlink(firmware_path) - - -@pytest.mark.asyncio -async def test_ota_concurrent_uploads(web_server_fixture): - """Test that concurrent OTA uploads are properly handled""" - base_url = web_server_fixture - firmware_path = await create_test_firmware() - - try: - async with aiohttp.ClientSession() as session: - # Create two concurrent upload tasks - async def upload_firmware(): - with open(firmware_path, "rb") as f: - data = aiohttp.FormData() - data.add_field( - "firmware", - f.read(), # Read to bytes to avoid file conflicts - filename="firmware.bin", - content_type="application/octet-stream", - ) - - async with session.post( - f"{base_url}/ota/upload", data=data - ) as resp: - return resp.status - - # Start two uploads concurrently - results = await asyncio.gather( - upload_firmware(), upload_firmware(), return_exceptions=True - ) - - # One should succeed, the other should fail with conflict - statuses = [r for r in results if isinstance(r, int)] - assert len(statuses) == 2 - assert 200 in statuses or 201 in statuses or 204 in statuses - # The other might be 409 Conflict or similar - - finally: - os.unlink(firmware_path) - - -@pytest.mark.asyncio -async def test_ota_large_file_upload(web_server_fixture): - """Test OTA upload with a larger file to test chunked processing""" - base_url = web_server_fixture - - # Create a larger test firmware (1MB) - with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: - # ESP32 magic bytes - f.write(b"\xe9\x08\x02\x20") - # Write 1MB of data in chunks - chunk_size = 4096 - for _ in range(256): # 256 * 4KB = 1MB - f.write(b"A" * chunk_size) - firmware_path = f.name - - try: - async with aiohttp.ClientSession() as session: - with open(firmware_path, "rb") as f: - data = aiohttp.FormData() - data.add_field( - "firmware", - f, - filename="large_firmware.bin", - content_type="application/octet-stream", - ) - - # Use a longer timeout for large file - timeout = aiohttp.ClientTimeout(total=60) - async with session.post( - f"{base_url}/ota/upload", data=data, timeout=timeout - ) as resp: - assert resp.status in [200, 201, 204], ( - f"Large file OTA upload failed with status {resp.status}" - ) - - finally: - os.unlink(firmware_path) - - -if __name__ == "__main__": - # For manual testing - asyncio.run(test_ota_upload_multipart(asyncio.Event())) From e01d16ce827f0554f6710773ec5515a9f00ba237 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:07:47 -0500 Subject: [PATCH 54/93] cleanup --- .../web_server_idf/web_server_idf.cpp | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 4322255589..01c3857367 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -151,10 +151,15 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { size_t total_len = r->content_len; size_t remaining = total_len; std::string current_filename; - bool upload_started = false; - // Track if we've started the upload - bool file_started = false; + // Upload state machine + enum class UploadState : uint8_t { + IDLE = 0, + FILE_FOUND, // Found file in multipart data + UPLOAD_STARTED, // Called handleUpload with index=0 + UPLOAD_COMPLETE // Called handleUpload with final=true + }; + UploadState upload_state = UploadState::IDLE; // Set up callbacks for the multipart reader reader.set_data_callback([&](const uint8_t *data, size_t len) { @@ -171,14 +176,14 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { // First time we see data for this file current_filename = reader.get_current_part().filename; ESP_LOGV(TAG, "Processing file part: '%s'", current_filename.c_str()); + upload_state = UploadState::FILE_FOUND; } - if (!file_started) { + if (upload_state == UploadState::FILE_FOUND) { // Initialize the upload with index=0 ESP_LOGV(TAG, "Starting upload for: '%s'", current_filename.c_str()); found_handler->handleUpload(&req, current_filename, 0, nullptr, 0, false); - file_started = true; - upload_started = true; + upload_state = UploadState::UPLOAD_STARTED; } // Process the data chunk immediately - the pointer won't be valid after this callback returns! @@ -190,13 +195,12 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { }); reader.set_part_complete_callback([&]() { - if (!current_filename.empty() && upload_started) { + if (upload_state == UploadState::UPLOAD_STARTED) { ESP_LOGV(TAG, "Part complete callback called for: '%s'", current_filename.c_str()); // Signal end of this part - final=true signals completion found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); + upload_state = UploadState::UPLOAD_COMPLETE; current_filename.clear(); - upload_started = false; - file_started = false; } }); @@ -226,10 +230,10 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { // Final cleanup - send final signal if upload was in progress // This should not be needed as part_complete_callback should handle it - if (!current_filename.empty() && upload_started) { + if (upload_state == UploadState::UPLOAD_STARTED) { ESP_LOGW(TAG, "Upload was not properly closed by part_complete_callback"); found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); - file_started = false; + upload_state = UploadState::UPLOAD_COMPLETE; } // Let handler send response From ca203bff9bd278029bd2e8bec2356b37b5cb688e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:18:33 -0500 Subject: [PATCH 55/93] cleanup --- esphome/components/web_server_idf/web_server_idf.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 01c3857367..e4ac871135 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -7,6 +7,7 @@ #include "esphome/core/log.h" #include "esp_tls_crypto.h" +#include #include "utils.h" #include "web_server_idf.h" @@ -143,7 +144,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { std::string full_boundary = "--" + boundary; ESP_LOGVV(TAG, "Initializing multipart reader with full boundary: '%s'", full_boundary.c_str()); MultipartReader reader(full_boundary); - static constexpr size_t CHUNK_SIZE = 1024; + static constexpr size_t CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size // IMPORTANT: chunk_buf is reused for each chunk read from the socket. // The multipart parser will pass pointers into this buffer to callbacks. // Those pointers are only valid during the callback execution! @@ -204,6 +205,9 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } }); + // Track chunks for watchdog feeding + int chunks_processed = 0; + while (remaining > 0) { size_t to_read = std::min(remaining, CHUNK_SIZE); int recv_len = httpd_req_recv(r, chunk_buf.get(), to_read); @@ -226,6 +230,12 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } remaining -= recv_len; + + // Feed watchdog every 10 chunks (~14KB with 1460 byte chunks) + chunks_processed++; + if (chunks_processed % 10 == 0) { + esp_task_wdt_reset(); + } } // Final cleanup - send final signal if upload was in progress From 0ac879ae0b41817a5321571d27ec9d51f7b8d935 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:22:13 -0500 Subject: [PATCH 56/93] remove --- esphome/components/web_server_idf/web_server_idf.cpp | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 4306767c80..0aac948484 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include "esp_tls_crypto.h" -#include #include "utils.h" #include "web_server_idf.h" @@ -205,9 +204,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } }); - // Track chunks for watchdog feeding - int chunks_processed = 0; - while (remaining > 0) { size_t to_read = std::min(remaining, CHUNK_SIZE); int recv_len = httpd_req_recv(r, chunk_buf.get(), to_read); @@ -230,12 +226,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } remaining -= recv_len; - - // Feed watchdog every 10 chunks (~14KB with 1460 byte chunks) - chunks_processed++; - if (chunks_processed % 10 == 0) { - esp_task_wdt_reset(); - } } // Final cleanup - send final signal if upload was in progress From 8e00fedc67dee4274e3ea498dcd2daf886a85896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:24:40 -0500 Subject: [PATCH 57/93] rwatchdog --- .../components/web_server_idf/web_server_idf.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 0aac948484..5ae08b5c73 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -7,6 +7,8 @@ #include "esphome/core/log.h" #include "esp_tls_crypto.h" +#include +#include #include "utils.h" #include "web_server_idf.h" @@ -226,6 +228,18 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } remaining -= recv_len; + + // Yield periodically to allow the main loop task to run and reset its watchdog + // The httpd thread doesn't need to reset the watchdog, but it needs to yield + // so the loopTask can run and reset its own watchdog + static int bytes_since_yield = 0; + bytes_since_yield += recv_len; + if (bytes_since_yield > 16 * 1024) { // Yield every 16KB + // Use vTaskDelay(1) to yield to other tasks + // This allows the main loop task to run and reset its watchdog + vTaskDelay(1); + bytes_since_yield = 0; + } } // Final cleanup - send final signal if upload was in progress From 59bcbe7fefd4f32151dac58d6fbf0c590375f566 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:31:01 -0500 Subject: [PATCH 58/93] proper state machine --- .../web_server_base/web_server_base.cpp | 19 +++++++++---------- .../web_server_base/web_server_base.h | 15 +++++++++++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 1db6dc43e8..9bbeb7b605 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -119,8 +119,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // ESP-IDF implementation if (index == 0) { this->ota_init_(filename.c_str()); - this->ota_started_ = false; - this->ota_success_ = false; + this->ota_state_ = OTAState::IDLE; // Create OTA backend auto backend = ota::make_ota_backend(); @@ -129,15 +128,16 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin auto result = backend->begin(0); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", result); + this->ota_state_ = OTAState::FAILED; return; } // Store the backend pointer this->ota_backend_ = backend.release(); - this->ota_started_ = true; + this->ota_state_ = OTAState::STARTED; } - if (!this->ota_started_ || !this->ota_backend_) { + if (this->ota_state_ != OTAState::STARTED && this->ota_state_ != OTAState::IN_PROGRESS) { // Begin failed or was aborted return; } @@ -145,6 +145,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // Write data if (len > 0) { auto *backend = static_cast(this->ota_backend_); + this->ota_state_ = OTAState::IN_PROGRESS; auto result = backend->write(data, len); if (result != ota::OTA_RESPONSE_OK) { @@ -152,8 +153,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin backend->abort(); delete backend; this->ota_backend_ = nullptr; - this->ota_started_ = false; - this->ota_success_ = false; + this->ota_state_ = OTAState::FAILED; return; } @@ -165,15 +165,14 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin auto *backend = static_cast(this->ota_backend_); auto result = backend->end(); if (result == ota::OTA_RESPONSE_OK) { - this->ota_success_ = true; + this->ota_state_ = OTAState::SUCCESS; this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", result); - this->ota_success_ = false; + this->ota_state_ = OTAState::FAILED; } delete backend; this->ota_backend_ = nullptr; - this->ota_started_ = false; } #endif // USE_ESP_IDF #endif // USE_WEBSERVER_OTA @@ -195,7 +194,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #ifdef USE_ESP_IDF // For ESP-IDF, we use direct send() instead of beginResponse() // to ensure the response is sent immediately before the reboot. - request->send(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!"); + request->send(200, "text/plain", this->ota_state_ == OTAState::SUCCESS ? "Update Successful!" : "Update Failed!"); return; #endif // USE_ESP_IDF response->addHeader("Connection", "close"); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index d6be110582..ac319ca4f7 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -130,8 +130,7 @@ class OTARequestHandler : public AsyncWebHandler { OTARequestHandler(WebServerBase *parent) : parent_(parent) { #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) this->ota_backend_ = nullptr; - this->ota_started_ = false; - this->ota_success_ = false; + this->ota_state_ = OTAState::IDLE; #endif } void handleRequest(AsyncWebServerRequest *request) override; @@ -157,9 +156,17 @@ class OTARequestHandler : public AsyncWebHandler { private: #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) + // OTA state machine + enum class OTAState : uint8_t{ + IDLE = 0, // No OTA in progress + STARTED, // OTA begin() succeeded + IN_PROGRESS, // Writing data + SUCCESS, // OTA end() succeeded + FAILED // OTA failed at any stage + }; + void *ota_backend_{nullptr}; - bool ota_started_{false}; - bool ota_success_{false}; + OTAState ota_state_{OTAState::IDLE}; #endif }; From 939144174c2a059fbd8e479a0e7516df7f6b0a19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:32:43 -0500 Subject: [PATCH 59/93] cleanup --- esphome/components/web_server_idf/multipart_reader.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index 53c207ded0..d60331d64f 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -48,15 +48,6 @@ size_t MultipartReader::parse(const char *data, size_t len) { if (parsed != len) { ESP_LOGW(TAG, "Parser consumed %zu of %zu bytes - possible error", parsed, len); - // Log the data around the error point - if (parsed < len && parsed < 32) { - ESP_LOGV(TAG, "Data at error point (offset %zu): %02x %02x %02x %02x", parsed, - parsed > 0 ? (uint8_t) data[parsed - 1] : 0, (uint8_t) data[parsed], - parsed + 1 < len ? (uint8_t) data[parsed + 1] : 0, parsed + 2 < len ? (uint8_t) data[parsed + 2] : 0); - - // Log what we have vs what parser expects - ESP_LOGV(TAG, "Parser error at position %zu: got '%c' (0x%02x)", parsed, data[parsed], (uint8_t) data[parsed]); - } } return parsed; From 1927f923581492c8dc60a1881f5876d61673e6f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:49:01 -0500 Subject: [PATCH 60/93] cleanup --- .../web_server_idf/multipart_reader.cpp | 37 +++++++------------ .../web_server_idf/multipart_reader.h | 5 +-- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp index d60331d64f..4810f34738 100644 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ b/esphome/components/web_server_idf/multipart_reader.cpp @@ -53,50 +53,41 @@ size_t MultipartReader::parse(const char *data, size_t len) { return parsed; } -void MultipartReader::process_header_() { +void MultipartReader::process_header_(const std::string &value) { + // Process the completed header (field + value pair) if (str_startswith_case_insensitive(current_header_field_, "content-disposition")) { // Parse name and filename from Content-Disposition - current_part_.name = extract_header_param(current_header_value_, "name"); - current_part_.filename = extract_header_param(current_header_value_, "filename"); + current_part_.name = extract_header_param(value, "name"); + current_part_.filename = extract_header_param(value, "filename"); } else if (str_startswith_case_insensitive(current_header_field_, "content-type")) { - current_part_.content_type = str_trim(current_header_value_); + current_part_.content_type = str_trim(value); } + + // Clear field for next header + current_header_field_.clear(); } int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - // If we were processing a value, save it - if (!reader->current_header_value_.empty()) { - reader->process_header_(); - reader->current_header_value_.clear(); - } - - // Start new header field + // Store the header field name reader->current_header_field_.assign(at, length); - reader->in_headers_ = true; - return 0; } int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - reader->current_header_value_.append(at, length); + + // Process the header immediately with the value + std::string value(at, length); + reader->process_header_(value); + return 0; } int MultipartReader::on_headers_complete(multipart_parser *parser) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - // Process last header if any - if (!reader->current_header_value_.empty()) { - reader->process_header_(); - } - - reader->in_headers_ = false; - reader->current_header_field_.clear(); - reader->current_header_value_.clear(); - ESP_LOGV(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(), reader->current_part_.content_type.c_str()); diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart_reader.h index 563e90e3cf..9d8f52cb1c 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart_reader.h @@ -56,14 +56,11 @@ class MultipartReader { Part current_part_; std::string current_header_field_; - std::string current_header_value_; DataCallback data_callback_; PartCompleteCallback part_complete_callback_; - bool in_headers_{false}; - - void process_header_(); + void process_header_(const std::string &value); }; } // namespace web_server_idf From ed2c3e626b49068616965980d22c50121073e6a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 19:53:29 -0500 Subject: [PATCH 61/93] cleanup --- esphome/components/web_server_idf/web_server_idf.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 5ae08b5c73..e4e0861292 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -49,9 +49,11 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; +#ifdef USE_WEBSERVER_OTA // Increase stack size for OTA operations - esp_ota_end() needs more stack // during image validation than the default 4096 bytes - config.stack_size = 6144; + config.stack_size = 4608; +#endif if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", From d065f4ae6270641b6375bc4ef8d47a4430ce298b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 20:15:18 -0500 Subject: [PATCH 62/93] cleanup --- .../web_server_idf/multipart_parser_utils.cpp | 40 +-------------- .../web_server_idf/multipart_parser_utils.h | 12 ----- .../web_server_idf/parser_utils.cpp | 51 +++++++++++++++++++ .../components/web_server_idf/parser_utils.h | 24 +++++++++ .../web_server_idf/web_server_idf.cpp | 45 +++++++++------- 5 files changed, 103 insertions(+), 69 deletions(-) create mode 100644 esphome/components/web_server_idf/parser_utils.cpp create mode 100644 esphome/components/web_server_idf/parser_utils.h diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart_parser_utils.cpp index de1906a0a6..a0869648f8 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.cpp +++ b/esphome/components/web_server_idf/multipart_parser_utils.cpp @@ -1,21 +1,12 @@ #include "esphome/core/defines.h" #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include "multipart_parser_utils.h" +#include "parser_utils.h" #include "esphome/core/log.h" namespace esphome { namespace web_server_idf { -// Helper function for case-insensitive string region comparison -bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { - for (size_t i = 0; i < n; i++) { - if (!char_equals_ci(s1[i], s2[i])) { - return false; - } - } - return true; -} - // Case-insensitive string prefix check bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { if (str.length() < prefix.length()) { @@ -108,26 +99,6 @@ std::string extract_header_param(const std::string &header, const std::string &p return ""; } -// Case-insensitive string search (like strstr but case-insensitive) -const char *stristr(const char *haystack, const char *needle) { - if (!haystack || !needle) { - return nullptr; - } - - size_t needle_len = strlen(needle); - if (needle_len == 0) { - return haystack; - } - - for (const char *p = haystack; *p; p++) { - if (str_ncmp_ci(p, needle, needle_len)) { - return p; - } - } - - return nullptr; -} - // Parse boundary from Content-Type header // Returns true if boundary found, false otherwise // boundary_start and boundary_len will point to the boundary value @@ -188,15 +159,6 @@ bool parse_multipart_boundary(const char *content_type, const char **boundary_st return true; } -// Check if content type is form-urlencoded (case-insensitive) -bool is_form_urlencoded(const char *content_type) { - if (!content_type) { - return false; - } - - return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; -} - // Trim whitespace from both ends of a string std::string str_trim(const std::string &str) { size_t start = str.find_first_not_of(" \t\r\n"); diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h index 1829a17b35..26f7d05b96 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ b/esphome/components/web_server_idf/multipart_parser_utils.h @@ -9,12 +9,6 @@ namespace esphome { namespace web_server_idf { -// Helper function for case-insensitive character comparison -inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } - -// Helper function for case-insensitive string region comparison -bool str_ncmp_ci(const char *s1, const char *s2, size_t n); - // Case-insensitive string prefix check bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix); @@ -25,17 +19,11 @@ size_t str_find_case_insensitive(const std::string &haystack, const std::string // Handles both quoted and unquoted values std::string extract_header_param(const std::string &header, const std::string ¶m); -// Case-insensitive string search (like strstr but case-insensitive) -const char *stristr(const char *haystack, const char *needle); - // Parse boundary from Content-Type header // Returns true if boundary found, false otherwise // boundary_start and boundary_len will point to the boundary value bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len); -// Check if content type is form-urlencoded (case-insensitive) -bool is_form_urlencoded(const char *content_type); - // Trim whitespace from both ends of a string std::string str_trim(const std::string &str); diff --git a/esphome/components/web_server_idf/parser_utils.cpp b/esphome/components/web_server_idf/parser_utils.cpp new file mode 100644 index 0000000000..6a9af37e24 --- /dev/null +++ b/esphome/components/web_server_idf/parser_utils.cpp @@ -0,0 +1,51 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP_IDF +#include "parser_utils.h" +#include +#include + +namespace esphome { +namespace web_server_idf { + +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { + for (size_t i = 0; i < n; i++) { + if (!char_equals_ci(s1[i], s2[i])) { + return false; + } + } + return true; +} + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle) { + if (!haystack || !needle) { + return nullptr; + } + + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return haystack; + } + + for (const char *p = haystack; *p; p++) { + if (str_ncmp_ci(p, needle, needle_len)) { + return p; + } + } + + return nullptr; +} + +// Check if content type is form-urlencoded (case-insensitive) +bool is_form_urlencoded(const char *content_type) { + if (!content_type) { + return false; + } + + return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; +} + +} // namespace web_server_idf +} // namespace esphome +#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/parser_utils.h b/esphome/components/web_server_idf/parser_utils.h new file mode 100644 index 0000000000..52c32849c6 --- /dev/null +++ b/esphome/components/web_server_idf/parser_utils.h @@ -0,0 +1,24 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_ESP_IDF + +#include + +namespace esphome { +namespace web_server_idf { + +// Helper function for case-insensitive character comparison +inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } + +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n); + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle); + +// Check if content type is form-urlencoded (case-insensitive) +bool is_form_urlencoded(const char *content_type); + +} // namespace web_server_idf +} // namespace esphome +#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e4e0861292..b7eac8369f 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -12,6 +14,7 @@ #include "utils.h" #include "web_server_idf.h" +#include "parser_utils.h" #ifdef USE_WEBSERVER_OTA #include "multipart_reader.h" @@ -88,40 +91,46 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { #ifdef USE_WEBSERVER_OTA // Check if this is a multipart form data request (for OTA updates) bool is_multipart = false; - std::string boundary; +#endif if (content_type.has_value()) { - const std::string &ct = content_type.value(); - const char *boundary_start = nullptr; - size_t boundary_len = 0; + const char *content_type_char = content_type.value().c_str(); - if (parse_multipart_boundary(ct.c_str(), &boundary_start, &boundary_len)) { - boundary.assign(boundary_start, boundary_len); + // Check most common case first + if (is_form_urlencoded(content_type_char)) { + // Normal form data - proceed with regular handling +#ifdef USE_WEBSERVER_OTA + } else if (stristr(content_type_char, "multipart/form-data") != nullptr) { is_multipart = true; - ESP_LOGV(TAG, "Multipart upload detected, boundary: '%s' (len: %zu)", boundary.c_str(), boundary_len); - } else if (!is_form_urlencoded(ct.c_str())) { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", ct.c_str()); +#endif + } else { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); // fallback to get handler to support backward compatibility return AsyncWebServer::request_handler(r); } } -#else - if (content_type.has_value() && content_type.value() != "application/x-www-form-urlencoded") { - ESP_LOGW(TAG, "Only application/x-www-form-urlencoded supported for POST request"); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); - } -#endif if (!request_has_header(r, "Content-Length")) { - ESP_LOGW(TAG, "Content length is requred for post: %s", r->uri); + ESP_LOGW(TAG, "Content length is required for post: %s", r->uri); httpd_resp_send_err(r, HTTPD_411_LENGTH_REQUIRED, nullptr); return ESP_OK; } #ifdef USE_WEBSERVER_OTA // Handle multipart form data - if (is_multipart && !boundary.empty()) { + if (is_multipart) { + // Parse the boundary from the content type + const char *boundary_start = nullptr; + size_t boundary_len = 0; + + if (!parse_multipart_boundary(content_type.value().c_str(), &boundary_start, &boundary_len)) { + ESP_LOGE(TAG, "Failed to parse multipart boundary"); + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + std::string boundary(boundary_start, boundary_len); + ESP_LOGV(TAG, "Multipart upload boundary: '%s'", boundary.c_str()); // Create request object AsyncWebServerRequest req(r); auto *server = static_cast(r->user_ctx); From f26bec1a5af6c845b95f9e1f6a0d8d6a898a5135 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 20:18:32 -0500 Subject: [PATCH 63/93] preen --- esphome/components/web_server_idf/parser_utils.cpp | 9 --------- esphome/components/web_server_idf/parser_utils.h | 3 --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/esphome/components/web_server_idf/parser_utils.cpp b/esphome/components/web_server_idf/parser_utils.cpp index 6a9af37e24..4ce82c760f 100644 --- a/esphome/components/web_server_idf/parser_utils.cpp +++ b/esphome/components/web_server_idf/parser_utils.cpp @@ -37,15 +37,6 @@ const char *stristr(const char *haystack, const char *needle) { return nullptr; } -// Check if content type is form-urlencoded (case-insensitive) -bool is_form_urlencoded(const char *content_type) { - if (!content_type) { - return false; - } - - return stristr(content_type, "application/x-www-form-urlencoded") != nullptr; -} - } // namespace web_server_idf } // namespace esphome #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/parser_utils.h b/esphome/components/web_server_idf/parser_utils.h index 52c32849c6..ed4d2341fb 100644 --- a/esphome/components/web_server_idf/parser_utils.h +++ b/esphome/components/web_server_idf/parser_utils.h @@ -16,9 +16,6 @@ bool str_ncmp_ci(const char *s1, const char *s2, size_t n); // Case-insensitive string search (like strstr but case-insensitive) const char *stristr(const char *haystack, const char *needle); -// Check if content type is form-urlencoded (case-insensitive) -bool is_form_urlencoded(const char *content_type); - } // namespace web_server_idf } // namespace esphome #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index b7eac8369f..b7f4f2d836 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -97,7 +97,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { const char *content_type_char = content_type.value().c_str(); // Check most common case first - if (is_form_urlencoded(content_type_char)) { + if (stristr(content_type_char, "application/x-www-form-urlencoded") != nullptr) { // Normal form data - proceed with regular handling #ifdef USE_WEBSERVER_OTA } else if (stristr(content_type_char, "multipart/form-data") != nullptr) { From f94703360bbd07e93f8e5b60aee52fa81adf126f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 20:54:13 -0500 Subject: [PATCH 64/93] cleanup --- ...17:53:09][D][sensor:104]: 'Lambda Senso.sh | 52 +++++++ ...ltipart_parser_utils.cpp => multipart.cpp} | 132 ++++++++++++++++- .../{multipart_reader.h => multipart.h} | 24 +++- .../web_server_idf/multipart_parser_utils.h | 32 ----- .../web_server_idf/multipart_reader.cpp | 136 ------------------ .../web_server_idf/web_server_idf.cpp | 3 +- 6 files changed, 206 insertions(+), 173 deletions(-) create mode 100644 esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh rename esphome/components/web_server_idf/{multipart_parser_utils.cpp => multipart.cpp} (50%) rename esphome/components/web_server_idf/{multipart_reader.h => multipart.h} (70%) delete mode 100644 esphome/components/web_server_idf/multipart_parser_utils.h delete mode 100644 esphome/components/web_server_idf/multipart_reader.cpp diff --git a/esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh b/esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh new file mode 100644 index 0000000000..c6db42cc4e --- /dev/null +++ b/esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh @@ -0,0 +1,52 @@ +[17:53:09][D][sensor:104]: 'Lambda Sensor 15': Sending state 15.00000 with 1 decimals of accuracy +[17:53:09][D][sensor:104]: 'Lambda Sensor 34': Sending state 34.00000 with 1 decimals of accuracy +[17:53:10][D][sensor:104]: 'Lambda Sensor 16': Sending state 16.00000 with 1 decimals of accuracy +[17:53:10][D][sensor:104]: 'Lambda Sensor 7': Sending state 7.00000 with 1 decimals of accuracy +[17:53:12][D][esp-idf:000]: W (92465) httpd_txrx: httpd_sock_err: error in send : 9 +[17:53:12]Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled. + +[17:53:12]Core 0 register dump: +[17:53:12]PC : 0x401a369f PS : 0x00060530 A0 : 0x801705f8 A1 : 0x3ffcc9d0 +WARNING Decoded 0x401a369f: std::local_Rb_tree_increment(std::_Rb_tree_node_base*) at /Users/brnomac003/.gitlab-runner/builds/qR2TxTby/0/idf/crosstool-NG/.build/xtensa-esp-elf/src/gcc/libstdc++-v3/src/c++98/tree.cc:65 +[17:53:12]A2 : 0x02000241 A3 : 0x3ffcc9c8 A4 : 0x00000008 A5 : 0x3ffe8b84 +[17:53:12]A6 : 0x30303030 A7 : 0x63383030 A8 : 0x3ffe8778 A9 : 0x02000241 +[17:53:12]A10 : 0xfffffffe A11 : 0x0000003b A12 : 0x3ffe8b7c A13 : 0x00000098 +[17:53:12]A14 : 0x00000000 A15 : 0x3ffe36c4 SAR : 0x00000017 EXCCAUSE: 0x0000001c +[17:53:12]EXCVADDR: 0x02000249 LBEG : 0x40082b85 LEND : 0x40082b8d LCOUNT : 0x00000027 + + +[17:53:12]Backtrace: 0x401a369c:0x3ffcc9d0 0x401705f5:0x3ffcc9f0 0x4010062e:0x3ffcca10 0x400f793a:0x3ffcca30 0x400f08c1:0x3ffcca50 0x400f094d:0x3ffcca80 0x401a03ad:0x3ffccac0 0x401a0461:0x3ffccae0 0x40101566:0x3ffccb00 0x4010586a:0x3ffccb30 0x400e6f76:0x3ffccb50 +WARNING Found stack trace! Trying to decode it +WARNING Decoded 0x401a369c: std::local_Rb_tree_increment(std::_Rb_tree_node_base*) at /Users/brnomac003/.gitlab-runner/builds/qR2TxTby/0/idf/crosstool-NG/.build/xtensa-esp-elf/src/gcc/libstdc++-v3/src/c++98/tree.cc:62 +WARNING Decoded 0x401705f5: std::_Rb_tree_increment(std::_Rb_tree_node_base const*) at /Users/brnomac003/.gitlab-runner/builds/qR2TxTby/0/idf/crosstool-NG/.build/xtensa-esp-elf/src/gcc/libstdc++-v3/src/c++98/tree.cc:89 +WARNING Decoded 0x4010062e: std::_Rb_tree_const_iterator::operator++() at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/stl_tree.h:368 + (inlined by) esphome::web_server_idf::AsyncEventSource::try_send_nodefer(char const*, char const*, unsigned long, unsigned long) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/web_server_idf/web_server_idf.cpp:516 +WARNING Decoded 0x400f793a: std::_Function_handler::_M_invoke(std::_Any_data const&, unsigned char&&, char const*&&, char const*&&) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/web_server/web_server.cpp:247 (discriminator 1) + (inlined by) __invoke_impl&, unsigned char, char const*, char const*> at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/invoke.h:61 (discriminator 1) + (inlined by) __invoke_r&, unsigned char, char const*, char const*> at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/invoke.h:111 (discriminator 1) + (inlined by) _M_invoke at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/std_function.h:290 (discriminator 1) +WARNING Decoded 0x400f08c1: std::function::operator()(unsigned char, char const*, char const*) const at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/std_function.h:591 + (inlined by) esphome::CallbackManager::call(unsigned char, char const*, char const*) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/helpers.h:431 +WARNING Decoded 0x400f094d: esphome::logger::Logger::loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/logger/logger.cpp:188 + (inlined by) esphome::logger::Logger::loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/logger/logger.cpp:155 +WARNING Decoded 0x401a03ad: esphome::Component::call_loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/component.cpp:84 +WARNING Decoded 0x401a0461: esphome::Component::call() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/component.cpp:112 +WARNING Decoded 0x40101566: esphome::Application::loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/application.cpp:128 +WARNING Decoded 0x4010586a: loop() at /Users/bdraco/esphome/.esphome/build/ol/ol.yaml:1345 +WARNING Decoded 0x400e6f76: esphome::loop_task(void*) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/esp32/core.cpp:86 (discriminator 1) + + + + +[17:53:14]ELF file SHA256: 009865893 + +[17:53:14]Rebooting... +[17:53:14]ets Jul 29 2019 12:21:46 + +[17:53:14]rst:0xc (SW_CPU_RESET),boot:0x1b (SPI_FAST_FLASH_BOOT) +[17:53:14]configsip: 0, SPIWP:0xee +[17:53:14]clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00 +[17:53:14]mode:DIO, clock div:2 +[17:53:14]load:0x3fff0030,len:6072 +[17:53:14]load:0x40078000,len:14960 +[17:53:14]load:0x40080400,len:4 diff --git a/esphome/components/web_server_idf/multipart_parser_utils.cpp b/esphome/components/web_server_idf/multipart.cpp similarity index 50% rename from esphome/components/web_server_idf/multipart_parser_utils.cpp rename to esphome/components/web_server_idf/multipart.cpp index a0869648f8..eb84016cf1 100644 --- a/esphome/components/web_server_idf/multipart_parser_utils.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -1,12 +1,140 @@ #include "esphome/core/defines.h" #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) -#include "multipart_parser_utils.h" +#include "multipart.h" #include "parser_utils.h" #include "esphome/core/log.h" +#include +#include "multipart_parser.h" namespace esphome { namespace web_server_idf { +static const char *const TAG = "multipart"; + +// ========== MultipartReader Implementation ========== + +MultipartReader::MultipartReader(const std::string &boundary) { + // Initialize settings with callbacks + memset(&settings_, 0, sizeof(settings_)); + settings_.on_header_field = on_header_field; + settings_.on_header_value = on_header_value; + settings_.on_part_data_begin = on_part_data_begin; + settings_.on_part_data = on_part_data; + settings_.on_part_data_end = on_part_data_end; + settings_.on_headers_complete = on_headers_complete; + + ESP_LOGV(TAG, "Initializing multipart parser with boundary: '%s' (len: %zu)", boundary.c_str(), boundary.length()); + + // Create parser with boundary + parser_ = multipart_parser_init(boundary.c_str(), &settings_); + if (parser_) { + multipart_parser_set_data(parser_, this); + } else { + ESP_LOGE(TAG, "Failed to initialize multipart parser"); + } +} + +MultipartReader::~MultipartReader() { + if (parser_) { + multipart_parser_free(parser_); + } +} + +size_t MultipartReader::parse(const char *data, size_t len) { + if (!parser_) { + ESP_LOGE(TAG, "Parser not initialized"); + return 0; + } + + size_t parsed = multipart_parser_execute(parser_, data, len); + + if (parsed != len) { + ESP_LOGW(TAG, "Parser consumed %zu of %zu bytes - possible error", parsed, len); + } + + return parsed; +} + +void MultipartReader::process_header_(const std::string &value) { + // Process the completed header (field + value pair) + if (str_startswith_case_insensitive(current_header_field_, "content-disposition")) { + // Parse name and filename from Content-Disposition + current_part_.name = extract_header_param(value, "name"); + current_part_.filename = extract_header_param(value, "filename"); + } else if (str_startswith_case_insensitive(current_header_field_, "content-type")) { + current_part_.content_type = str_trim(value); + } + + // Clear field for next header + current_header_field_.clear(); +} + +int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + // Store the header field name + reader->current_header_field_.assign(at, length); + return 0; +} + +int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + // Process the header immediately with the value + std::string value(at, length); + reader->process_header_(value); + + return 0; +} + +int MultipartReader::on_headers_complete(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + ESP_LOGV(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", + reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(), + reader->current_part_.content_type.c_str()); + + return 0; +} + +int MultipartReader::on_part_data_begin(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + ESP_LOGV(TAG, "Part data begin"); + return 0; +} + +int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + // Only process file uploads + if (reader->has_file() && reader->data_callback_) { + // IMPORTANT: The 'at' pointer points to data within the parser's input buffer. + // This data is only valid during this callback. The callback handler MUST + // process or copy the data immediately - it cannot store the pointer for + // later use as the buffer will be overwritten. + reader->data_callback_(reinterpret_cast(at), length); + } + + return 0; +} + +int MultipartReader::on_part_data_end(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + + ESP_LOGV(TAG, "Part data end"); + + if (reader->part_complete_callback_) { + reader->part_complete_callback_(); + } + + // Clear part info for next part + reader->current_part_ = Part{}; + + return 0; +} + +// ========== Utility Functions ========== + // Case-insensitive string prefix check bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { if (str.length() < prefix.length()) { @@ -171,4 +299,4 @@ std::string str_trim(const std::string &str) { } // namespace web_server_idf } // namespace esphome -#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) \ No newline at end of file diff --git a/esphome/components/web_server_idf/multipart_reader.h b/esphome/components/web_server_idf/multipart.h similarity index 70% rename from esphome/components/web_server_idf/multipart_reader.h rename to esphome/components/web_server_idf/multipart.h index 9d8f52cb1c..0cf727584e 100644 --- a/esphome/components/web_server_idf/multipart_reader.h +++ b/esphome/components/web_server_idf/multipart.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include namespace esphome { namespace web_server_idf { @@ -63,6 +65,26 @@ class MultipartReader { void process_header_(const std::string &value); }; +// ========== Utility Functions ========== + +// Case-insensitive string prefix check +bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix); + +// Find a substring case-insensitively +size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos = 0); + +// Extract a parameter value from a header line +// Handles both quoted and unquoted values +std::string extract_header_param(const std::string &header, const std::string ¶m); + +// Parse boundary from Content-Type header +// Returns true if boundary found, false otherwise +// boundary_start and boundary_len will point to the boundary value +bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len); + +// Trim whitespace from both ends of a string +std::string str_trim(const std::string &str); + } // namespace web_server_idf } // namespace esphome -#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) \ No newline at end of file diff --git a/esphome/components/web_server_idf/multipart_parser_utils.h b/esphome/components/web_server_idf/multipart_parser_utils.h deleted file mode 100644 index 26f7d05b96..0000000000 --- a/esphome/components/web_server_idf/multipart_parser_utils.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - -#include -#include -#include - -namespace esphome { -namespace web_server_idf { - -// Case-insensitive string prefix check -bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix); - -// Find a substring case-insensitively -size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos = 0); - -// Extract a parameter value from a header line -// Handles both quoted and unquoted values -std::string extract_header_param(const std::string &header, const std::string ¶m); - -// Parse boundary from Content-Type header -// Returns true if boundary found, false otherwise -// boundary_start and boundary_len will point to the boundary value -bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len); - -// Trim whitespace from both ends of a string -std::string str_trim(const std::string &str); - -} // namespace web_server_idf -} // namespace esphome -#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart_reader.cpp b/esphome/components/web_server_idf/multipart_reader.cpp deleted file mode 100644 index 4810f34738..0000000000 --- a/esphome/components/web_server_idf/multipart_reader.cpp +++ /dev/null @@ -1,136 +0,0 @@ -#include "esphome/core/defines.h" -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) -#include "multipart_reader.h" -#include "multipart_parser_utils.h" -#include "esphome/core/log.h" -#include -#include "multipart_parser.h" - -namespace esphome { -namespace web_server_idf { - -static const char *const TAG = "multipart_reader"; - -MultipartReader::MultipartReader(const std::string &boundary) { - // Initialize settings with callbacks - memset(&settings_, 0, sizeof(settings_)); - settings_.on_header_field = on_header_field; - settings_.on_header_value = on_header_value; - settings_.on_part_data_begin = on_part_data_begin; - settings_.on_part_data = on_part_data; - settings_.on_part_data_end = on_part_data_end; - settings_.on_headers_complete = on_headers_complete; - - ESP_LOGV(TAG, "Initializing multipart parser with boundary: '%s' (len: %zu)", boundary.c_str(), boundary.length()); - - // Create parser with boundary - parser_ = multipart_parser_init(boundary.c_str(), &settings_); - if (parser_) { - multipart_parser_set_data(parser_, this); - } else { - ESP_LOGE(TAG, "Failed to initialize multipart parser"); - } -} - -MultipartReader::~MultipartReader() { - if (parser_) { - multipart_parser_free(parser_); - } -} - -size_t MultipartReader::parse(const char *data, size_t len) { - if (!parser_) { - ESP_LOGE(TAG, "Parser not initialized"); - return 0; - } - - size_t parsed = multipart_parser_execute(parser_, data, len); - - if (parsed != len) { - ESP_LOGW(TAG, "Parser consumed %zu of %zu bytes - possible error", parsed, len); - } - - return parsed; -} - -void MultipartReader::process_header_(const std::string &value) { - // Process the completed header (field + value pair) - if (str_startswith_case_insensitive(current_header_field_, "content-disposition")) { - // Parse name and filename from Content-Disposition - current_part_.name = extract_header_param(value, "name"); - current_part_.filename = extract_header_param(value, "filename"); - } else if (str_startswith_case_insensitive(current_header_field_, "content-type")) { - current_part_.content_type = str_trim(value); - } - - // Clear field for next header - current_header_field_.clear(); -} - -int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - // Store the header field name - reader->current_header_field_.assign(at, length); - return 0; -} - -int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - // Process the header immediately with the value - std::string value(at, length); - reader->process_header_(value); - - return 0; -} - -int MultipartReader::on_headers_complete(multipart_parser *parser) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - ESP_LOGV(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", - reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(), - reader->current_part_.content_type.c_str()); - - return 0; -} - -int MultipartReader::on_part_data_begin(multipart_parser *parser) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - ESP_LOGV(TAG, "Part data begin"); - return 0; -} - -int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - // Only process file uploads - if (reader->has_file() && reader->data_callback_) { - // IMPORTANT: The 'at' pointer points to data within the parser's input buffer. - // This data is only valid during this callback. The callback handler MUST - // process or copy the data immediately - it cannot store the pointer for - // later use as the buffer will be overwritten. - reader->data_callback_(reinterpret_cast(at), length); - } - - return 0; -} - -int MultipartReader::on_part_data_end(multipart_parser *parser) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - ESP_LOGV(TAG, "Part data end"); - - if (reader->part_complete_callback_) { - reader->part_complete_callback_(); - } - - // Clear part info for next part - reader->current_part_ = Part{}; - - return 0; -} - -} // namespace web_server_idf -} // namespace esphome -#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index b7f4f2d836..82e73e035a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -17,8 +17,7 @@ #include "parser_utils.h" #ifdef USE_WEBSERVER_OTA -#include "multipart_reader.h" -#include "multipart_parser_utils.h" +#include "multipart.h" #endif #ifdef USE_WEBSERVER From bb22f4d6a3fa3ee678ee624fc8a6419495cfcc20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 20:54:36 -0500 Subject: [PATCH 65/93] cleanup --- ...17:53:09][D][sensor:104]: 'Lambda Senso.sh | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh diff --git a/esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh b/esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh deleted file mode 100644 index c6db42cc4e..0000000000 --- a/esphome/components/web_server_idf/[17:53:09][D][sensor:104]: 'Lambda Senso.sh +++ /dev/null @@ -1,52 +0,0 @@ -[17:53:09][D][sensor:104]: 'Lambda Sensor 15': Sending state 15.00000 with 1 decimals of accuracy -[17:53:09][D][sensor:104]: 'Lambda Sensor 34': Sending state 34.00000 with 1 decimals of accuracy -[17:53:10][D][sensor:104]: 'Lambda Sensor 16': Sending state 16.00000 with 1 decimals of accuracy -[17:53:10][D][sensor:104]: 'Lambda Sensor 7': Sending state 7.00000 with 1 decimals of accuracy -[17:53:12][D][esp-idf:000]: W (92465) httpd_txrx: httpd_sock_err: error in send : 9 -[17:53:12]Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled. - -[17:53:12]Core 0 register dump: -[17:53:12]PC : 0x401a369f PS : 0x00060530 A0 : 0x801705f8 A1 : 0x3ffcc9d0 -WARNING Decoded 0x401a369f: std::local_Rb_tree_increment(std::_Rb_tree_node_base*) at /Users/brnomac003/.gitlab-runner/builds/qR2TxTby/0/idf/crosstool-NG/.build/xtensa-esp-elf/src/gcc/libstdc++-v3/src/c++98/tree.cc:65 -[17:53:12]A2 : 0x02000241 A3 : 0x3ffcc9c8 A4 : 0x00000008 A5 : 0x3ffe8b84 -[17:53:12]A6 : 0x30303030 A7 : 0x63383030 A8 : 0x3ffe8778 A9 : 0x02000241 -[17:53:12]A10 : 0xfffffffe A11 : 0x0000003b A12 : 0x3ffe8b7c A13 : 0x00000098 -[17:53:12]A14 : 0x00000000 A15 : 0x3ffe36c4 SAR : 0x00000017 EXCCAUSE: 0x0000001c -[17:53:12]EXCVADDR: 0x02000249 LBEG : 0x40082b85 LEND : 0x40082b8d LCOUNT : 0x00000027 - - -[17:53:12]Backtrace: 0x401a369c:0x3ffcc9d0 0x401705f5:0x3ffcc9f0 0x4010062e:0x3ffcca10 0x400f793a:0x3ffcca30 0x400f08c1:0x3ffcca50 0x400f094d:0x3ffcca80 0x401a03ad:0x3ffccac0 0x401a0461:0x3ffccae0 0x40101566:0x3ffccb00 0x4010586a:0x3ffccb30 0x400e6f76:0x3ffccb50 -WARNING Found stack trace! Trying to decode it -WARNING Decoded 0x401a369c: std::local_Rb_tree_increment(std::_Rb_tree_node_base*) at /Users/brnomac003/.gitlab-runner/builds/qR2TxTby/0/idf/crosstool-NG/.build/xtensa-esp-elf/src/gcc/libstdc++-v3/src/c++98/tree.cc:62 -WARNING Decoded 0x401705f5: std::_Rb_tree_increment(std::_Rb_tree_node_base const*) at /Users/brnomac003/.gitlab-runner/builds/qR2TxTby/0/idf/crosstool-NG/.build/xtensa-esp-elf/src/gcc/libstdc++-v3/src/c++98/tree.cc:89 -WARNING Decoded 0x4010062e: std::_Rb_tree_const_iterator::operator++() at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/stl_tree.h:368 - (inlined by) esphome::web_server_idf::AsyncEventSource::try_send_nodefer(char const*, char const*, unsigned long, unsigned long) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/web_server_idf/web_server_idf.cpp:516 -WARNING Decoded 0x400f793a: std::_Function_handler::_M_invoke(std::_Any_data const&, unsigned char&&, char const*&&, char const*&&) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/web_server/web_server.cpp:247 (discriminator 1) - (inlined by) __invoke_impl&, unsigned char, char const*, char const*> at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/invoke.h:61 (discriminator 1) - (inlined by) __invoke_r&, unsigned char, char const*, char const*> at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/invoke.h:111 (discriminator 1) - (inlined by) _M_invoke at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/std_function.h:290 (discriminator 1) -WARNING Decoded 0x400f08c1: std::function::operator()(unsigned char, char const*, char const*) const at /Users/bdraco/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include/c++/13.2.0/bits/std_function.h:591 - (inlined by) esphome::CallbackManager::call(unsigned char, char const*, char const*) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/helpers.h:431 -WARNING Decoded 0x400f094d: esphome::logger::Logger::loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/logger/logger.cpp:188 - (inlined by) esphome::logger::Logger::loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/logger/logger.cpp:155 -WARNING Decoded 0x401a03ad: esphome::Component::call_loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/component.cpp:84 -WARNING Decoded 0x401a0461: esphome::Component::call() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/component.cpp:112 -WARNING Decoded 0x40101566: esphome::Application::loop() at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/core/application.cpp:128 -WARNING Decoded 0x4010586a: loop() at /Users/bdraco/esphome/.esphome/build/ol/ol.yaml:1345 -WARNING Decoded 0x400e6f76: esphome::loop_task(void*) at /Users/bdraco/esphome/.esphome/build/ol/src/esphome/components/esp32/core.cpp:86 (discriminator 1) - - - - -[17:53:14]ELF file SHA256: 009865893 - -[17:53:14]Rebooting... -[17:53:14]ets Jul 29 2019 12:21:46 - -[17:53:14]rst:0xc (SW_CPU_RESET),boot:0x1b (SPI_FAST_FLASH_BOOT) -[17:53:14]configsip: 0, SPIWP:0xee -[17:53:14]clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00 -[17:53:14]mode:DIO, clock div:2 -[17:53:14]load:0x3fff0030,len:6072 -[17:53:14]load:0x40078000,len:14960 -[17:53:14]load:0x40080400,len:4 From 148e4ec5550baf221d2a494f0e15fb809d162ada Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 20:59:51 -0500 Subject: [PATCH 66/93] cleanup --- .../components/web_server_idf/multipart.cpp | 18 ------------------ esphome/components/web_server_idf/multipart.h | 2 -- 2 files changed, 20 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index eb84016cf1..ebbcf93e3b 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -18,10 +18,8 @@ MultipartReader::MultipartReader(const std::string &boundary) { memset(&settings_, 0, sizeof(settings_)); settings_.on_header_field = on_header_field; settings_.on_header_value = on_header_value; - settings_.on_part_data_begin = on_part_data_begin; settings_.on_part_data = on_part_data; settings_.on_part_data_end = on_part_data_end; - settings_.on_headers_complete = on_headers_complete; ESP_LOGV(TAG, "Initializing multipart parser with boundary: '%s' (len: %zu)", boundary.c_str(), boundary.length()); @@ -87,22 +85,6 @@ int MultipartReader::on_header_value(multipart_parser *parser, const char *at, s return 0; } -int MultipartReader::on_headers_complete(multipart_parser *parser) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - ESP_LOGV(TAG, "Part headers complete: name='%s', filename='%s', content_type='%s'", - reader->current_part_.name.c_str(), reader->current_part_.filename.c_str(), - reader->current_part_.content_type.c_str()); - - return 0; -} - -int MultipartReader::on_part_data_begin(multipart_parser *parser) { - MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - ESP_LOGV(TAG, "Part data begin"); - return 0; -} - int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index 0cf727584e..d912f100da 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -48,10 +48,8 @@ class MultipartReader { private: static int on_header_field(multipart_parser *parser, const char *at, size_t length); static int on_header_value(multipart_parser *parser, const char *at, size_t length); - static int on_part_data_begin(multipart_parser *parser); static int on_part_data(multipart_parser *parser, const char *at, size_t length); static int on_part_data_end(multipart_parser *parser); - static int on_headers_complete(multipart_parser *parser); multipart_parser *parser_{nullptr}; multipart_parser_settings settings_{}; From 429be0a5ae8166b003140bcf3cb7358e6bf3f8f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:03:13 -0500 Subject: [PATCH 67/93] cleanup --- esphome/components/web_server_idf/multipart.cpp | 13 +++++++------ esphome/components/web_server_idf/multipart.h | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index ebbcf93e3b..3f58ae165a 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -53,14 +53,16 @@ size_t MultipartReader::parse(const char *data, size_t len) { return parsed; } -void MultipartReader::process_header_(const std::string &value) { +void MultipartReader::process_header_(const char *value, size_t length) { // Process the completed header (field + value pair) + std::string value_str(value, length); + if (str_startswith_case_insensitive(current_header_field_, "content-disposition")) { // Parse name and filename from Content-Disposition - current_part_.name = extract_header_param(value, "name"); - current_part_.filename = extract_header_param(value, "filename"); + current_part_.name = extract_header_param(value_str, "name"); + current_part_.filename = extract_header_param(value_str, "filename"); } else if (str_startswith_case_insensitive(current_header_field_, "content-type")) { - current_part_.content_type = str_trim(value); + current_part_.content_type = str_trim(value_str); } // Clear field for next header @@ -79,8 +81,7 @@ int MultipartReader::on_header_value(multipart_parser *parser, const char *at, s MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); // Process the header immediately with the value - std::string value(at, length); - reader->process_header_(value); + reader->process_header_(at, length); return 0; } diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index d912f100da..d9a7da88e1 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -60,7 +60,7 @@ class MultipartReader { DataCallback data_callback_; PartCompleteCallback part_complete_callback_; - void process_header_(const std::string &value); + void process_header_(const char *value, size_t length); }; // ========== Utility Functions ========== From f5df5f71a3376aefb58e59b8a72b01457959c2a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:04:45 -0500 Subject: [PATCH 68/93] cleanup --- esphome/components/web_server_idf/multipart.cpp | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 3f58ae165a..db9fc5173b 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -71,24 +71,18 @@ void MultipartReader::process_header_(const char *value, size_t length) { int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - // Store the header field name reader->current_header_field_.assign(at, length); return 0; } int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - - // Process the header immediately with the value reader->process_header_(at, length); - return 0; } int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - // Only process file uploads if (reader->has_file() && reader->data_callback_) { // IMPORTANT: The 'at' pointer points to data within the parser's input buffer. @@ -97,22 +91,17 @@ int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size // later use as the buffer will be overwritten. reader->data_callback_(reinterpret_cast(at), length); } - return 0; } int MultipartReader::on_part_data_end(multipart_parser *parser) { MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); - ESP_LOGV(TAG, "Part data end"); - if (reader->part_complete_callback_) { reader->part_complete_callback_(); } - // Clear part info for next part reader->current_part_ = Part{}; - return 0; } @@ -282,4 +271,4 @@ std::string str_trim(const std::string &str) { } // namespace web_server_idf } // namespace esphome -#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) \ No newline at end of file +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) From 849d99b0dcef8700e6b491e2628a58117c296bf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:06:04 -0500 Subject: [PATCH 69/93] cleanup --- esphome/components/web_server_idf/parser_utils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/parser_utils.cpp b/esphome/components/web_server_idf/parser_utils.cpp index 4ce82c760f..fb88dd1a15 100644 --- a/esphome/components/web_server_idf/parser_utils.cpp +++ b/esphome/components/web_server_idf/parser_utils.cpp @@ -19,7 +19,7 @@ bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { // Case-insensitive string search (like strstr but case-insensitive) const char *stristr(const char *haystack, const char *needle) { - if (!haystack || !needle) { + if (!haystack) { return nullptr; } From ad4dd6a060d81661483ce374626215fe80cbd47b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:07:39 -0500 Subject: [PATCH 70/93] cleanup --- .../components/web_server_idf/multipart.cpp | 2 +- .../web_server_idf/parser_utils.cpp | 42 ------------------- .../components/web_server_idf/parser_utils.h | 21 ---------- esphome/components/web_server_idf/utils.cpp | 32 ++++++++++++++ esphome/components/web_server_idf/utils.h | 10 +++++ .../web_server_idf/web_server_idf.cpp | 1 - 6 files changed, 43 insertions(+), 65 deletions(-) delete mode 100644 esphome/components/web_server_idf/parser_utils.cpp delete mode 100644 esphome/components/web_server_idf/parser_utils.h diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index db9fc5173b..7944ad4e5d 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -1,7 +1,7 @@ #include "esphome/core/defines.h" #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) #include "multipart.h" -#include "parser_utils.h" +#include "utils.h" #include "esphome/core/log.h" #include #include "multipart_parser.h" diff --git a/esphome/components/web_server_idf/parser_utils.cpp b/esphome/components/web_server_idf/parser_utils.cpp deleted file mode 100644 index fb88dd1a15..0000000000 --- a/esphome/components/web_server_idf/parser_utils.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include "esphome/core/defines.h" -#ifdef USE_ESP_IDF -#include "parser_utils.h" -#include -#include - -namespace esphome { -namespace web_server_idf { - -// Helper function for case-insensitive string region comparison -bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { - for (size_t i = 0; i < n; i++) { - if (!char_equals_ci(s1[i], s2[i])) { - return false; - } - } - return true; -} - -// Case-insensitive string search (like strstr but case-insensitive) -const char *stristr(const char *haystack, const char *needle) { - if (!haystack) { - return nullptr; - } - - size_t needle_len = strlen(needle); - if (needle_len == 0) { - return haystack; - } - - for (const char *p = haystack; *p; p++) { - if (str_ncmp_ci(p, needle, needle_len)) { - return p; - } - } - - return nullptr; -} - -} // namespace web_server_idf -} // namespace esphome -#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/parser_utils.h b/esphome/components/web_server_idf/parser_utils.h deleted file mode 100644 index ed4d2341fb..0000000000 --- a/esphome/components/web_server_idf/parser_utils.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_ESP_IDF - -#include - -namespace esphome { -namespace web_server_idf { - -// Helper function for case-insensitive character comparison -inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } - -// Helper function for case-insensitive string region comparison -bool str_ncmp_ci(const char *s1, const char *s2, size_t n); - -// Case-insensitive string search (like strstr but case-insensitive) -const char *stristr(const char *haystack, const char *needle); - -} // namespace web_server_idf -} // namespace esphome -#endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index 349acce50d..ac5df90bb8 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -1,5 +1,7 @@ #ifdef USE_ESP_IDF #include +#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "http_parser.h" @@ -88,6 +90,36 @@ optional query_key_value(const std::string &query_url, const std::s return {val.get()}; } +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { + for (size_t i = 0; i < n; i++) { + if (!char_equals_ci(s1[i], s2[i])) { + return false; + } + } + return true; +} + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle) { + if (!haystack) { + return nullptr; + } + + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return haystack; + } + + for (const char *p = haystack; *p; p++) { + if (str_ncmp_ci(p, needle, needle_len)) { + return p; + } + } + + return nullptr; +} + } // namespace web_server_idf } // namespace esphome #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index 9ed17c1d50..988b962d72 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -2,6 +2,7 @@ #ifdef USE_ESP_IDF #include +#include #include "esphome/core/helpers.h" namespace esphome { @@ -12,6 +13,15 @@ optional request_get_header(httpd_req_t *req, const char *name); optional request_get_url_query(httpd_req_t *req); optional query_key_value(const std::string &query_url, const std::string &key); +// Helper function for case-insensitive character comparison +inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } + +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n); + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle); + } // namespace web_server_idf } // namespace esphome #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 82e73e035a..6897c9d7d6 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -14,7 +14,6 @@ #include "utils.h" #include "web_server_idf.h" -#include "parser_utils.h" #ifdef USE_WEBSERVER_OTA #include "multipart.h" From 01e550fac911b0e26c3f1ced25c7ac7f70f934f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:13:05 -0500 Subject: [PATCH 71/93] cleanup --- .../web_server_idf/web_server_idf.cpp | 274 +++++++----------- .../web_server_idf/web_server_idf.h | 3 + 2 files changed, 113 insertions(+), 164 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 6897c9d7d6..7fbc79afe0 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -86,10 +86,11 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { ESP_LOGVV(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); auto content_type = request_get_header(r, "Content-Type"); -#ifdef USE_WEBSERVER_OTA - // Check if this is a multipart form data request (for OTA updates) - bool is_multipart = false; -#endif + if (!request_has_header(r, "Content-Length")) { + ESP_LOGW(TAG, "Content length is required for post: %s", r->uri); + httpd_resp_send_err(r, HTTPD_411_LENGTH_REQUIRED, nullptr); + return ESP_OK; + } if (content_type.has_value()) { const char *content_type_char = content_type.value().c_str(); @@ -99,7 +100,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { // Normal form data - proceed with regular handling #ifdef USE_WEBSERVER_OTA } else if (stristr(content_type_char, "multipart/form-data") != nullptr) { - is_multipart = true; + return this->handle_multipart_upload_(r, content_type_char); #endif } else { ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); @@ -108,165 +109,6 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } } - if (!request_has_header(r, "Content-Length")) { - ESP_LOGW(TAG, "Content length is required for post: %s", r->uri); - httpd_resp_send_err(r, HTTPD_411_LENGTH_REQUIRED, nullptr); - return ESP_OK; - } - -#ifdef USE_WEBSERVER_OTA - // Handle multipart form data - if (is_multipart) { - // Parse the boundary from the content type - const char *boundary_start = nullptr; - size_t boundary_len = 0; - - if (!parse_multipart_boundary(content_type.value().c_str(), &boundary_start, &boundary_len)) { - ESP_LOGE(TAG, "Failed to parse multipart boundary"); - httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); - return ESP_FAIL; - } - - std::string boundary(boundary_start, boundary_len); - ESP_LOGV(TAG, "Multipart upload boundary: '%s'", boundary.c_str()); - // Create request object - AsyncWebServerRequest req(r); - auto *server = static_cast(r->user_ctx); - - // Find handler that can handle this request - AsyncWebHandler *found_handler = nullptr; - for (auto *handler : server->handlers_) { - if (handler->canHandle(&req)) { - found_handler = handler; - ESP_LOGD(TAG, "Found handler for OTA request"); - break; - } - } - - if (!found_handler) { - ESP_LOGW(TAG, "No handler found for OTA request"); - httpd_resp_send_err(r, HTTPD_404_NOT_FOUND, nullptr); - return ESP_OK; - } - - // Handle multipart upload using the multipart-parser library - // The multipart data starts with "--" + boundary, so we need to prepend it - std::string full_boundary = "--" + boundary; - ESP_LOGVV(TAG, "Initializing multipart reader with full boundary: '%s'", full_boundary.c_str()); - MultipartReader reader(full_boundary); - static constexpr size_t CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size - // IMPORTANT: chunk_buf is reused for each chunk read from the socket. - // The multipart parser will pass pointers into this buffer to callbacks. - // Those pointers are only valid during the callback execution! - std::unique_ptr chunk_buf(new char[CHUNK_SIZE]); - size_t total_len = r->content_len; - size_t remaining = total_len; - std::string current_filename; - - // Upload state machine - enum class UploadState : uint8_t { - IDLE = 0, - FILE_FOUND, // Found file in multipart data - UPLOAD_STARTED, // Called handleUpload with index=0 - UPLOAD_COMPLETE // Called handleUpload with final=true - }; - UploadState upload_state = UploadState::IDLE; - - // Set up callbacks for the multipart reader - reader.set_data_callback([&](const uint8_t *data, size_t len) { - // CRITICAL: The data pointer is only valid during this callback! - // The multipart parser passes pointers into the chunk_buf buffer, which will be - // overwritten when we read the next chunk. We MUST process the data immediately - // within this callback - any deferred processing will result in use-after-free bugs - // where the data pointer points to corrupted/overwritten memory. - - // By the time on_part_data is called, on_headers_complete has already been called - // so we can check for filename - if (reader.has_file()) { - if (current_filename.empty()) { - // First time we see data for this file - current_filename = reader.get_current_part().filename; - ESP_LOGV(TAG, "Processing file part: '%s'", current_filename.c_str()); - upload_state = UploadState::FILE_FOUND; - } - - if (upload_state == UploadState::FILE_FOUND) { - // Initialize the upload with index=0 - ESP_LOGV(TAG, "Starting upload for: '%s'", current_filename.c_str()); - found_handler->handleUpload(&req, current_filename, 0, nullptr, 0, false); - upload_state = UploadState::UPLOAD_STARTED; - } - - // Process the data chunk immediately - the pointer won't be valid after this callback returns! - // DO NOT store the data pointer for later use or pass it to any async/deferred operations. - if (len > 0) { - found_handler->handleUpload(&req, current_filename, 1, const_cast(data), len, false); - } - } - }); - - reader.set_part_complete_callback([&]() { - if (upload_state == UploadState::UPLOAD_STARTED) { - ESP_LOGV(TAG, "Part complete callback called for: '%s'", current_filename.c_str()); - // Signal end of this part - final=true signals completion - found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); - upload_state = UploadState::UPLOAD_COMPLETE; - current_filename.clear(); - } - }); - - while (remaining > 0) { - size_t to_read = std::min(remaining, CHUNK_SIZE); - int recv_len = httpd_req_recv(r, chunk_buf.get(), to_read); - - if (recv_len <= 0) { - if (recv_len == HTTPD_SOCK_ERR_TIMEOUT) { - httpd_resp_send_err(r, HTTPD_408_REQ_TIMEOUT, nullptr); - return ESP_ERR_TIMEOUT; - } - httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); - return ESP_FAIL; - } - - size_t parsed = reader.parse(chunk_buf.get(), recv_len); - if (parsed != recv_len) { - ESP_LOGW(TAG, "Multipart parser error at byte %zu (parsed %zu of %d bytes)", total_len - remaining + parsed, - parsed, recv_len); - httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); - return ESP_FAIL; - } - - remaining -= recv_len; - - // Yield periodically to allow the main loop task to run and reset its watchdog - // The httpd thread doesn't need to reset the watchdog, but it needs to yield - // so the loopTask can run and reset its own watchdog - static int bytes_since_yield = 0; - bytes_since_yield += recv_len; - if (bytes_since_yield > 16 * 1024) { // Yield every 16KB - // Use vTaskDelay(1) to yield to other tasks - // This allows the main loop task to run and reset its watchdog - vTaskDelay(1); - bytes_since_yield = 0; - } - } - - // Final cleanup - send final signal if upload was in progress - // This should not be needed as part_complete_callback should handle it - if (upload_state == UploadState::UPLOAD_STARTED) { - ESP_LOGW(TAG, "Upload was not properly closed by part_complete_callback"); - found_handler->handleUpload(&req, current_filename, 2, nullptr, 0, true); - upload_state = UploadState::UPLOAD_COMPLETE; - } - - // Let handler send response - ESP_LOGV(TAG, "Calling handleRequest for OTA response"); - found_handler->handleRequest(&req); - ESP_LOGV(TAG, "handleRequest completed"); - return ESP_OK; - } -#endif // USE_WEBSERVER_OTA - // Handle regular form data if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { ESP_LOGW(TAG, "Request size is to big: %zu", r->content_len); @@ -727,6 +569,110 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e } #endif +#ifdef USE_WEBSERVER_OTA +esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { + // Parse boundary from content type + const char *boundary_start = nullptr; + size_t boundary_len = 0; + if (!parse_multipart_boundary(content_type, &boundary_start, &boundary_len)) { + ESP_LOGE(TAG, "Failed to parse multipart boundary"); + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + // Create request and find handler + AsyncWebServerRequest req(r); + AsyncWebHandler *handler = nullptr; + for (auto *h : this->handlers_) { + if (h->canHandle(&req)) { + handler = h; + break; + } + } + + if (!handler) { + ESP_LOGW(TAG, "No handler found for OTA request"); + httpd_resp_send_err(r, HTTPD_404_NOT_FOUND, nullptr); + return ESP_OK; + } + + // Initialize multipart reader + std::string boundary(boundary_start, boundary_len); + MultipartReader reader("--" + boundary); + + // Upload handling state + struct UploadContext { + AsyncWebHandler *handler; + AsyncWebServerRequest *req; + std::string filename; + bool started = false; + } ctx{handler, &req}; + + // Configure callbacks + reader.set_data_callback([&ctx, &reader](const uint8_t *data, size_t len) { + if (!reader.has_file() || len == 0) + return; + + if (ctx.filename.empty()) { + ctx.filename = reader.get_current_part().filename; + ESP_LOGV(TAG, "Processing file: '%s'", ctx.filename.c_str()); + } + + if (!ctx.started) { + ctx.handler->handleUpload(ctx.req, ctx.filename, 0, nullptr, 0, false); + ctx.started = true; + } + + ctx.handler->handleUpload(ctx.req, ctx.filename, 1, const_cast(data), len, false); + }); + + reader.set_part_complete_callback([&ctx]() { + if (ctx.started) { + ctx.handler->handleUpload(ctx.req, ctx.filename, 2, nullptr, 0, true); + ctx.filename.clear(); + ctx.started = false; + } + }); + + // Process chunks + static constexpr size_t CHUNK_SIZE = 1460; + std::unique_ptr buffer(new char[CHUNK_SIZE]); + size_t remaining = r->content_len; + size_t bytes_since_yield = 0; + + while (remaining > 0) { + size_t to_read = std::min(remaining, CHUNK_SIZE); + int recv_len = httpd_req_recv(r, buffer.get(), to_read); + + if (recv_len <= 0) { + httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, + nullptr); + return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + + size_t parsed = reader.parse(buffer.get(), recv_len); + if (parsed != recv_len) { + ESP_LOGW(TAG, "Multipart parser error"); + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + remaining -= recv_len; + bytes_since_yield += recv_len; + + // Yield periodically to let main loop run + if (bytes_since_yield > 16 * 1024) { + vTaskDelay(1); + bytes_since_yield = 0; + } + } + + // Let handler send response + handler->handleRequest(&req); + return ESP_OK; +} +#endif // USE_WEBSERVER_OTA + } // namespace web_server_idf } // namespace esphome diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 7547117224..8de25c8e96 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -204,6 +204,9 @@ class AsyncWebServer { static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; +#ifdef USE_WEBSERVER_OTA + esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); +#endif std::vector handlers_; std::function on_not_found_{}; }; From a43caf08a613f95d5a0ebe7de7ca05fcdcaf6e2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:31:54 -0500 Subject: [PATCH 72/93] cleanup --- esphome/components/web_server_idf/web_server_idf.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 7fbc79afe0..519a982b23 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -100,7 +100,8 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { // Normal form data - proceed with regular handling #ifdef USE_WEBSERVER_OTA } else if (stristr(content_type_char, "multipart/form-data") != nullptr) { - return this->handle_multipart_upload_(r, content_type_char); + auto *server = static_cast(r->user_ctx); + return server->handle_multipart_upload_(r, content_type_char); #endif } else { ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); From 9778289d333ad104a7fe298d15dafcf968feb9f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:36:25 -0500 Subject: [PATCH 73/93] revert --- esphome/components/web_server_idf/web_server_idf.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 519a982b23..2be9418d78 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -154,11 +154,7 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const this->on_not_found_(request); return ESP_OK; } - // No handler found - send 404 response - // This prevents "uri handler execution failed" warnings - ESP_LOGD(TAG, "No handler found for URL: %s (method: %d)", request->url().c_str(), request->method()); - request->send(404, "text/plain", "Not Found"); - return ESP_OK; + return ESP_ERR_NOT_FOUND; } AsyncWebServerRequest::~AsyncWebServerRequest() { From 8c8dd7b4bc3fc256de8c243336bd9c800400fcbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:40:20 -0500 Subject: [PATCH 74/93] preen --- esphome/components/web_server_idf/multipart.h | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index d9a7da88e1..3edb61978a 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -85,4 +85,4 @@ std::string str_trim(const std::string &str); } // namespace web_server_idf } // namespace esphome -#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) \ No newline at end of file +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2be9418d78..eb3a6b8401 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,7 +16,8 @@ #include "web_server_idf.h" #ifdef USE_WEBSERVER_OTA -#include "multipart.h" +#include +#include "multipart.h" // For parse_multipart_boundary and other utils #endif #ifdef USE_WEBSERVER From 004f4b51d111dd506180ad743023654e639718c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:41:57 -0500 Subject: [PATCH 75/93] preen --- .../web_server_idf/web_server_idf.cpp | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index eb3a6b8401..d51b3485cc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -569,6 +569,14 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e #ifdef USE_WEBSERVER_OTA esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { + // Constants for upload handling + static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size + static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog + + // Upload indices for handleUpload callbacks + static constexpr size_t UPLOAD_INDEX_BEGIN = 0; + static constexpr size_t UPLOAD_INDEX_WRITE = 1; + static constexpr size_t UPLOAD_INDEX_END = 2; // Parse boundary from content type const char *boundary_start = nullptr; size_t boundary_len = 0; @@ -617,29 +625,28 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } if (!ctx.started) { - ctx.handler->handleUpload(ctx.req, ctx.filename, 0, nullptr, 0, false); + ctx.handler->handleUpload(ctx.req, ctx.filename, UPLOAD_INDEX_BEGIN, nullptr, 0, false); ctx.started = true; } - ctx.handler->handleUpload(ctx.req, ctx.filename, 1, const_cast(data), len, false); + ctx.handler->handleUpload(ctx.req, ctx.filename, UPLOAD_INDEX_WRITE, const_cast(data), len, false); }); reader.set_part_complete_callback([&ctx]() { if (ctx.started) { - ctx.handler->handleUpload(ctx.req, ctx.filename, 2, nullptr, 0, true); + ctx.handler->handleUpload(ctx.req, ctx.filename, UPLOAD_INDEX_END, nullptr, 0, true); ctx.filename.clear(); ctx.started = false; } }); // Process chunks - static constexpr size_t CHUNK_SIZE = 1460; - std::unique_ptr buffer(new char[CHUNK_SIZE]); + std::unique_ptr buffer(new char[MULTIPART_CHUNK_SIZE]); size_t remaining = r->content_len; size_t bytes_since_yield = 0; while (remaining > 0) { - size_t to_read = std::min(remaining, CHUNK_SIZE); + size_t to_read = std::min(remaining, MULTIPART_CHUNK_SIZE); int recv_len = httpd_req_recv(r, buffer.get(), to_read); if (recv_len <= 0) { @@ -659,7 +666,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c bytes_since_yield += recv_len; // Yield periodically to let main loop run - if (bytes_since_yield > 16 * 1024) { + if (bytes_since_yield > YIELD_INTERVAL_BYTES) { vTaskDelay(1); bytes_since_yield = 0; } From 6968772a3123b5de558fa482b3e0e586f8aff3cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:48:35 -0500 Subject: [PATCH 76/93] preen --- .../web_server_idf/web_server_idf.cpp | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d51b3485cc..ebd51b481e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -572,11 +572,6 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c // Constants for upload handling static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog - - // Upload indices for handleUpload callbacks - static constexpr size_t UPLOAD_INDEX_BEGIN = 0; - static constexpr size_t UPLOAD_INDEX_WRITE = 1; - static constexpr size_t UPLOAD_INDEX_END = 2; // Parse boundary from content type const char *boundary_start = nullptr; size_t boundary_len = 0; @@ -611,7 +606,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c AsyncWebHandler *handler; AsyncWebServerRequest *req; std::string filename; - bool started = false; + size_t index = 0; // Byte position in the current upload } ctx{handler, &req}; // Configure callbacks @@ -624,19 +619,22 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c ESP_LOGV(TAG, "Processing file: '%s'", ctx.filename.c_str()); } - if (!ctx.started) { - ctx.handler->handleUpload(ctx.req, ctx.filename, UPLOAD_INDEX_BEGIN, nullptr, 0, false); - ctx.started = true; + if (ctx.index == 0) { + // First call with index 0 to indicate start of upload + ctx.handler->handleUpload(ctx.req, ctx.filename, 0, nullptr, 0, false); } - ctx.handler->handleUpload(ctx.req, ctx.filename, UPLOAD_INDEX_WRITE, const_cast(data), len, false); + // Write data with current index + ctx.handler->handleUpload(ctx.req, ctx.filename, ctx.index, const_cast(data), len, false); + ctx.index += len; }); reader.set_part_complete_callback([&ctx]() { - if (ctx.started) { - ctx.handler->handleUpload(ctx.req, ctx.filename, UPLOAD_INDEX_END, nullptr, 0, true); + if (ctx.index > 0) { + // Final call with final=true to indicate end of upload + ctx.handler->handleUpload(ctx.req, ctx.filename, ctx.index, nullptr, 0, true); ctx.filename.clear(); - ctx.started = false; + ctx.index = 0; } }); From 22cb59b88cea9902133e83eee9d9dbddbcb9a16d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:55:13 -0500 Subject: [PATCH 77/93] clean --- .../web_server_base/web_server_base.cpp | 43 ++++++++----------- .../web_server_base/web_server_base.h | 18 +------- 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 9bbeb7b605..30cc82e1c6 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -117,43 +117,37 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin #ifdef USE_ESP_IDF // ESP-IDF implementation - if (index == 0) { + auto *backend = static_cast(this->ota_backend_); + + if (index == 0 && !backend) { + // Only initialize once when backend doesn't exist this->ota_init_(filename.c_str()); - this->ota_state_ = OTAState::IDLE; + this->ota_success_ = false; // Reset success flag - // Create OTA backend - auto backend = ota::make_ota_backend(); - - // Begin OTA with unknown size - auto result = backend->begin(0); + // Create and begin OTA + auto new_backend = ota::make_ota_backend(); + auto result = new_backend->begin(0); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", result); - this->ota_state_ = OTAState::FAILED; return; } - // Store the backend pointer - this->ota_backend_ = backend.release(); - this->ota_state_ = OTAState::STARTED; + this->ota_backend_ = new_backend.release(); + backend = static_cast(this->ota_backend_); } - if (this->ota_state_ != OTAState::STARTED && this->ota_state_ != OTAState::IN_PROGRESS) { - // Begin failed or was aborted - return; + if (!backend) { + return; // Begin failed or was aborted } - // Write data + // Write data if provided if (len > 0) { - auto *backend = static_cast(this->ota_backend_); - this->ota_state_ = OTAState::IN_PROGRESS; - auto result = backend->write(data, len); if (result != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA write failed: %d", result); backend->abort(); delete backend; this->ota_backend_ = nullptr; - this->ota_state_ = OTAState::FAILED; return; } @@ -161,15 +155,14 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin this->report_ota_progress_(request); } + // Finalize if requested if (final) { - auto *backend = static_cast(this->ota_backend_); auto result = backend->end(); - if (result == ota::OTA_RESPONSE_OK) { - this->ota_state_ = OTAState::SUCCESS; + this->ota_success_ = (result == ota::OTA_RESPONSE_OK); + if (this->ota_success_) { this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", result); - this->ota_state_ = OTAState::FAILED; } delete backend; this->ota_backend_ = nullptr; @@ -194,7 +187,9 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #ifdef USE_ESP_IDF // For ESP-IDF, we use direct send() instead of beginResponse() // to ensure the response is sent immediately before the reboot. - request->send(200, "text/plain", this->ota_state_ == OTAState::SUCCESS ? "Update Successful!" : "Update Failed!"); + // If ota_backend_ is nullptr and we got here, the update completed (either success or failure) + // We'll use ota_success_ flag set by handleUpload to determine the result + request->send(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!"); return; #endif // USE_ESP_IDF response->addHeader("Connection", "close"); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index ac319ca4f7..ab5ca17fda 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -127,12 +127,7 @@ class WebServerBase : public Component { class OTARequestHandler : public AsyncWebHandler { public: - OTARequestHandler(WebServerBase *parent) : parent_(parent) { -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - this->ota_backend_ = nullptr; - this->ota_state_ = OTAState::IDLE; -#endif - } + 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; @@ -156,17 +151,8 @@ class OTARequestHandler : public AsyncWebHandler { private: #if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) - // OTA state machine - enum class OTAState : uint8_t{ - IDLE = 0, // No OTA in progress - STARTED, // OTA begin() succeeded - IN_PROGRESS, // Writing data - SUCCESS, // OTA end() succeeded - FAILED // OTA failed at any stage - }; - void *ota_backend_{nullptr}; - OTAState ota_state_{OTAState::IDLE}; + bool ota_success_{false}; #endif }; From a054aa9c528069db0fce66fd7fff6c9b05c5a6fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 21:57:50 -0500 Subject: [PATCH 78/93] clean --- .../web_server_base/web_server_base.cpp | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 30cc82e1c6..5ae80eedb4 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -117,52 +117,44 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin #ifdef USE_ESP_IDF // ESP-IDF implementation - auto *backend = static_cast(this->ota_backend_); - - if (index == 0 && !backend) { - // Only initialize once when backend doesn't exist + if (index == 0 && !this->ota_backend_) { + // Initialize OTA on first call this->ota_init_(filename.c_str()); - this->ota_success_ = false; // Reset success flag + this->ota_success_ = false; - // Create and begin OTA - auto new_backend = ota::make_ota_backend(); - auto result = new_backend->begin(0); - if (result != ota::OTA_RESPONSE_OK) { - ESP_LOGE(TAG, "OTA begin failed: %d", result); + auto backend = ota::make_ota_backend(); + if (backend->begin(0) != ota::OTA_RESPONSE_OK) { + ESP_LOGE(TAG, "OTA begin failed"); return; } - - this->ota_backend_ = new_backend.release(); - backend = static_cast(this->ota_backend_); + this->ota_backend_ = backend.release(); } + auto *backend = static_cast(this->ota_backend_); if (!backend) { - return; // Begin failed or was aborted + return; } - // Write data if provided + // Process data if (len > 0) { - auto result = backend->write(data, len); - if (result != ota::OTA_RESPONSE_OK) { - ESP_LOGE(TAG, "OTA write failed: %d", result); + if (backend->write(data, len) != ota::OTA_RESPONSE_OK) { + 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 requested + // Finalize if (final) { - auto result = backend->end(); - this->ota_success_ = (result == ota::OTA_RESPONSE_OK); + this->ota_success_ = (backend->end() == ota::OTA_RESPONSE_OK); if (this->ota_success_) { this->schedule_ota_reboot_(); } else { - ESP_LOGE(TAG, "OTA end failed: %d", result); + ESP_LOGE(TAG, "OTA end failed"); } delete backend; this->ota_backend_ = nullptr; From 7f6ac2deee5072ea49e9751e27a4a496b28a8a8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:10:50 -0500 Subject: [PATCH 79/93] tweak --- .../web_server_idf/web_server_idf.cpp | 71 +++++++------------ 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index ebd51b481e..1a5155b8cd 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -569,19 +569,21 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e #ifdef USE_WEBSERVER_OTA esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { - // Constants for upload handling static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog - // Parse boundary from content type - const char *boundary_start = nullptr; - size_t boundary_len = 0; + + // Parse boundary and create reader + const char *boundary_start; + size_t boundary_len; if (!parse_multipart_boundary(content_type, &boundary_start, &boundary_len)) { ESP_LOGE(TAG, "Failed to parse multipart boundary"); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; } - // Create request and find handler + MultipartReader reader("--" + std::string(boundary_start, boundary_len)); + + // Find handler AsyncWebServerRequest req(r); AsyncWebHandler *handler = nullptr; for (auto *h : this->handlers_) { @@ -597,55 +599,39 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c return ESP_OK; } - // Initialize multipart reader - std::string boundary(boundary_start, boundary_len); - MultipartReader reader("--" + boundary); - - // Upload handling state - struct UploadContext { - AsyncWebHandler *handler; - AsyncWebServerRequest *req; - std::string filename; - size_t index = 0; // Byte position in the current upload - } ctx{handler, &req}; + // Upload state + std::string filename; + size_t index = 0; // Configure callbacks - reader.set_data_callback([&ctx, &reader](const uint8_t *data, size_t len) { - if (!reader.has_file() || len == 0) + reader.set_data_callback([&](const uint8_t *data, size_t len) { + if (!reader.has_file() || !len) return; - if (ctx.filename.empty()) { - ctx.filename = reader.get_current_part().filename; - ESP_LOGV(TAG, "Processing file: '%s'", ctx.filename.c_str()); + if (filename.empty()) { + filename = reader.get_current_part().filename; + ESP_LOGV(TAG, "Processing file: '%s'", filename.c_str()); + handler->handleUpload(&req, filename, 0, nullptr, 0, false); // Start } - if (ctx.index == 0) { - // First call with index 0 to indicate start of upload - ctx.handler->handleUpload(ctx.req, ctx.filename, 0, nullptr, 0, false); - } - - // Write data with current index - ctx.handler->handleUpload(ctx.req, ctx.filename, ctx.index, const_cast(data), len, false); - ctx.index += len; + handler->handleUpload(&req, filename, index, const_cast(data), len, false); + index += len; }); - reader.set_part_complete_callback([&ctx]() { - if (ctx.index > 0) { - // Final call with final=true to indicate end of upload - ctx.handler->handleUpload(ctx.req, ctx.filename, ctx.index, nullptr, 0, true); - ctx.filename.clear(); - ctx.index = 0; + reader.set_part_complete_callback([&]() { + if (index > 0) { + handler->handleUpload(&req, filename, index, nullptr, 0, true); // End + filename.clear(); + index = 0; } }); - // Process chunks + // Process data std::unique_ptr buffer(new char[MULTIPART_CHUNK_SIZE]); - size_t remaining = r->content_len; size_t bytes_since_yield = 0; - while (remaining > 0) { - size_t to_read = std::min(remaining, MULTIPART_CHUNK_SIZE); - int recv_len = httpd_req_recv(r, buffer.get(), to_read); + for (size_t remaining = r->content_len; remaining > 0;) { + int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); if (recv_len <= 0) { httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, @@ -653,8 +639,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; } - size_t parsed = reader.parse(buffer.get(), recv_len); - if (parsed != recv_len) { + if (reader.parse(buffer.get(), recv_len) != static_cast(recv_len)) { ESP_LOGW(TAG, "Multipart parser error"); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; @@ -663,14 +648,12 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c remaining -= recv_len; bytes_since_yield += recv_len; - // Yield periodically to let main loop run if (bytes_since_yield > YIELD_INTERVAL_BYTES) { vTaskDelay(1); bytes_since_yield = 0; } } - // Let handler send response handler->handleRequest(&req); return ESP_OK; } From 94845222ad7cabb9d6caf77c38d75d45a91e2ae0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:12:20 -0500 Subject: [PATCH 80/93] tweak --- .../web_server_idf/web_server_idf.cpp | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 1a5155b8cd..c3a7734230 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -581,13 +581,14 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c return ESP_FAIL; } - MultipartReader reader("--" + std::string(boundary_start, boundary_len)); + // Create reader on heap to reduce stack usage + auto reader = std::make_unique("--" + std::string(boundary_start, boundary_len)); - // Find handler - AsyncWebServerRequest req(r); + // Find handler - create request on heap to reduce stack usage + auto req = std::make_unique(r); AsyncWebHandler *handler = nullptr; for (auto *h : this->handlers_) { - if (h->canHandle(&req)) { + if (h->canHandle(req.get())) { handler = h; break; } @@ -604,23 +605,23 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c size_t index = 0; // Configure callbacks - reader.set_data_callback([&](const uint8_t *data, size_t len) { - if (!reader.has_file() || !len) + reader->set_data_callback([&](const uint8_t *data, size_t len) { + if (!reader->has_file() || !len) return; if (filename.empty()) { - filename = reader.get_current_part().filename; + filename = reader->get_current_part().filename; ESP_LOGV(TAG, "Processing file: '%s'", filename.c_str()); - handler->handleUpload(&req, filename, 0, nullptr, 0, false); // Start + handler->handleUpload(req.get(), filename, 0, nullptr, 0, false); // Start } - handler->handleUpload(&req, filename, index, const_cast(data), len, false); + handler->handleUpload(req.get(), filename, index, const_cast(data), len, false); index += len; }); - reader.set_part_complete_callback([&]() { + reader->set_part_complete_callback([&]() { if (index > 0) { - handler->handleUpload(&req, filename, index, nullptr, 0, true); // End + handler->handleUpload(req.get(), filename, index, nullptr, 0, true); // End filename.clear(); index = 0; } @@ -639,7 +640,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; } - if (reader.parse(buffer.get(), recv_len) != static_cast(recv_len)) { + if (reader->parse(buffer.get(), recv_len) != static_cast(recv_len)) { ESP_LOGW(TAG, "Multipart parser error"); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; @@ -654,7 +655,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } } - handler->handleRequest(&req); + handler->handleRequest(req.get()); return ESP_OK; } #endif // USE_WEBSERVER_OTA From 2e4d7301f2e90943f35d52af379858bf4b398bb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:12:36 -0500 Subject: [PATCH 81/93] tweak --- esphome/components/web_server_idf/web_server_idf.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index c3a7734230..bd4de8cd06 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -51,11 +51,6 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; -#ifdef USE_WEBSERVER_OTA - // Increase stack size for OTA operations - esp_ota_end() needs more stack - // during image validation than the default 4096 bytes - config.stack_size = 4608; -#endif if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", From a74adb5865f91c7c81710138e8aa29287c885cf9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:13:56 -0500 Subject: [PATCH 82/93] tweak --- .../components/web_server_idf/web_server_idf.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index bd4de8cd06..16ddb8a28e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -579,11 +579,11 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c // Create reader on heap to reduce stack usage auto reader = std::make_unique("--" + std::string(boundary_start, boundary_len)); - // Find handler - create request on heap to reduce stack usage - auto req = std::make_unique(r); + // Find handler - keep request on stack since constructor is protected + AsyncWebServerRequest req(r); AsyncWebHandler *handler = nullptr; for (auto *h : this->handlers_) { - if (h->canHandle(req.get())) { + if (h->canHandle(&req)) { handler = h; break; } @@ -607,16 +607,16 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c if (filename.empty()) { filename = reader->get_current_part().filename; ESP_LOGV(TAG, "Processing file: '%s'", filename.c_str()); - handler->handleUpload(req.get(), filename, 0, nullptr, 0, false); // Start + handler->handleUpload(&req, filename, 0, nullptr, 0, false); // Start } - handler->handleUpload(req.get(), filename, index, const_cast(data), len, false); + handler->handleUpload(&req, filename, index, const_cast(data), len, false); index += len; }); reader->set_part_complete_callback([&]() { if (index > 0) { - handler->handleUpload(req.get(), filename, index, nullptr, 0, true); // End + handler->handleUpload(&req, filename, index, nullptr, 0, true); // End filename.clear(); index = 0; } @@ -650,7 +650,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } } - handler->handleRequest(req.get()); + handler->handleRequest(&req); return ESP_OK; } #endif // USE_WEBSERVER_OTA From 4082634e6d135c4be8ff88262ff0f31ee5ce9208 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:14:15 -0500 Subject: [PATCH 83/93] tweak --- esphome/components/web_server_idf/web_server_idf.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 16ddb8a28e..eac0544512 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -579,7 +579,6 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c // Create reader on heap to reduce stack usage auto reader = std::make_unique("--" + std::string(boundary_start, boundary_len)); - // Find handler - keep request on stack since constructor is protected AsyncWebServerRequest req(r); AsyncWebHandler *handler = nullptr; for (auto *h : this->handlers_) { From 8563a5785f6c424bcf86cdd7fe1b293b1cfb5f3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:19:29 -0500 Subject: [PATCH 84/93] tweak --- esphome/components/web_server_base/web_server_base.cpp | 5 +---- esphome/components/web_server_idf/web_server_idf.cpp | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 5ae80eedb4..91e9b408bc 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -177,10 +177,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { } #endif // USE_ARDUINO #ifdef USE_ESP_IDF - // For ESP-IDF, we use direct send() instead of beginResponse() - // to ensure the response is sent immediately before the reboot. - // If ota_backend_ is nullptr and we got here, the update completed (either success or failure) - // We'll use ota_success_ flag set by handleUpload to determine the result + // Send response based on the OTA result request->send(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!"); return; #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index eac0544512..9478e4748c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -576,9 +576,6 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c return ESP_FAIL; } - // Create reader on heap to reduce stack usage - auto reader = std::make_unique("--" + std::string(boundary_start, boundary_len)); - AsyncWebServerRequest req(r); AsyncWebHandler *handler = nullptr; for (auto *h : this->handlers_) { @@ -597,6 +594,8 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c // Upload state std::string filename; size_t index = 0; + // Create reader on heap to reduce stack usage + auto reader = std::make_unique("--" + std::string(boundary_start, boundary_len)); // Configure callbacks reader->set_data_callback([&](const uint8_t *data, size_t len) { From 727161f1db6c9fb86e7a24d447d550ee0869fb6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:24:28 -0500 Subject: [PATCH 85/93] tweak --- esphome/components/captive_portal/captive_portal.cpp | 2 ++ esphome/components/web_server/web_server.cpp | 2 ++ esphome/components/web_server_base/web_server_base.cpp | 5 +++-- esphome/components/web_server_base/web_server_base.h | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 51e5cfc8ff..ba392bb0f2 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -47,7 +47,9 @@ 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/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 88bb0bbe77..6625c77523 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -256,8 +256,10 @@ void WebServer::setup() { #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_OTA if (this->allow_ota_) this->base_->add_ota_handler(); +#endif // 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_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 91e9b408bc..0ddfddd845 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -186,11 +186,12 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #endif // USE_WEBSERVER_OTA } -void WebServerBase::add_ota_handler() { #ifdef USE_WEBSERVER_OTA +void WebServerBase::add_ota_handler() { this->add_handler(new OTARequestHandler(this)); // NOLINT -#endif } +#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 ab5ca17fda..db1379de74 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -110,7 +110,9 @@ 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_; } From 9f1fae0955c263ee3d4fd8106a395e8f56040b90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:27:36 -0500 Subject: [PATCH 86/93] tweak --- esphome/components/web_server_base/web_server_base.cpp | 8 +++----- esphome/components/web_server_base/web_server_base.h | 8 +++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 0ddfddd845..90d418dec1 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -63,6 +63,7 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) { } } +#ifdef USE_WEBSERVER_OTA void report_ota_error() { #ifdef USE_ARDUINO StreamString ss; @@ -70,10 +71,8 @@ void report_ota_error() { 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_WEBSERVER_OTA #ifdef USE_ARDUINO bool success; if (index == 0) { @@ -160,10 +159,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin this->ota_backend_ = nullptr; } #endif // USE_ESP_IDF -#endif // USE_WEBSERVER_OTA } + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { -#ifdef USE_WEBSERVER_OTA ESP_LOGV(TAG, "OTA handleRequest called"); AsyncWebServerResponse *response; #ifdef USE_ARDUINO @@ -183,8 +181,8 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { #endif // USE_ESP_IDF response->addHeader("Connection", "close"); request->send(response); -#endif // USE_WEBSERVER_OTA } +#endif // USE_WEBSERVER_OTA #ifdef USE_WEBSERVER_OTA void WebServerBase::add_ota_handler() { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index db1379de74..09a41956c9 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -118,7 +118,9 @@ class WebServerBase : public Component { uint16_t get_port() const { return port_; } protected: +#ifdef USE_WEBSERVER_OTA friend class OTARequestHandler; +#endif int initialized_{0}; uint16_t port_{80}; @@ -127,6 +129,7 @@ class WebServerBase : public Component { internal::Credentials credentials_; }; +#ifdef USE_WEBSERVER_OTA class OTARequestHandler : public AsyncWebHandler { public: OTARequestHandler(WebServerBase *parent) : parent_(parent) {} @@ -141,22 +144,21 @@ class OTARequestHandler : public AsyncWebHandler { bool isRequestHandlerTrivial() const override { return false; } protected: -#ifdef USE_WEBSERVER_OTA 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}; -#endif WebServerBase *parent_; private: -#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#ifdef USE_ESP_IDF void *ota_backend_{nullptr}; bool ota_success_{false}; #endif }; +#endif // USE_WEBSERVER_OTA } // namespace web_server_base } // namespace esphome From 8648954b944093a85e09583da8cadb39b045f24f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:29:40 -0500 Subject: [PATCH 87/93] tweak --- esphome/components/web_server_base/web_server_base.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 90d418dec1..ceb89756fd 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -162,7 +162,6 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin } void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { - ESP_LOGV(TAG, "OTA handleRequest called"); AsyncWebServerResponse *response; #ifdef USE_ARDUINO if (!Update.hasError()) { @@ -182,9 +181,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { response->addHeader("Connection", "close"); request->send(response); } -#endif // USE_WEBSERVER_OTA -#ifdef USE_WEBSERVER_OTA void WebServerBase::add_ota_handler() { this->add_handler(new OTARequestHandler(this)); // NOLINT } From 4106b971742a96006e9e59961b34fe356d7bbf74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:31:47 -0500 Subject: [PATCH 88/93] tweak --- .../web_server_base/web_server_base.cpp | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index ceb89756fd..39cae36b2d 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -23,6 +23,18 @@ namespace web_server_base { static const char *const TAG = "web_server_base"; +void WebServerBase::add_handler(AsyncWebHandler *handler) { + // remove all handlers + + if (!credentials_.username.empty()) { + handler = new internal::AuthMiddlewareHandler(handler, &credentials_); + } + this->handlers_.push_back(handler); + if (this->server_ != nullptr) { + this->server_->addHandler(handler); + } +} + #ifdef USE_WEBSERVER_OTA void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { const uint32_t now = millis(); @@ -49,21 +61,7 @@ void OTARequestHandler::ota_init_(const char *filename) { ESP_LOGI(TAG, "OTA Update Start: %s", filename); this->ota_read_length_ = 0; } -#endif -void WebServerBase::add_handler(AsyncWebHandler *handler) { - // remove all handlers - - if (!credentials_.username.empty()) { - handler = new internal::AuthMiddlewareHandler(handler, &credentials_); - } - this->handlers_.push_back(handler); - if (this->server_ != nullptr) { - this->server_->addHandler(handler); - } -} - -#ifdef USE_WEBSERVER_OTA void report_ota_error() { #ifdef USE_ARDUINO StreamString ss; @@ -71,6 +69,7 @@ void report_ota_error() { 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 From fe65b149f5d254f7933a155466c856dabd47128c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 22:34:42 -0500 Subject: [PATCH 89/93] tweak --- esphome/components/web_server_idf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index dfb32107e8..cc453cb60e 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,6 +1,6 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option import esphome.config_validation as cv -from esphome.const import CONF_OTA +from esphome.const import CONF_OTA, CONF_WEB_SERVER from esphome.core import CORE CODEOWNERS = ["@dentra"] @@ -16,7 +16,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) # Check if web_server component has OTA enabled - web_server_config = CORE.config.get("web_server", {}) + web_server_config = CORE.config.get(CONF_WEB_SERVER, {}) if web_server_config and web_server_config[CONF_OTA] and "ota" in CORE.config: # Add multipart parser component for ESP-IDF OTA support add_idf_component(name="zorxx/multipart-parser", ref="1.0.1") From 918d7217a93a684cbb5dbbe45356c7943cb944d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 23:15:28 -0500 Subject: [PATCH 90/93] fix --- tests/components/web_server/test_ota.esp32-idf.yaml | 1 - tests/components/web_server/test_ota_disabled.esp32-idf.yaml | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/components/web_server/test_ota.esp32-idf.yaml b/tests/components/web_server/test_ota.esp32-idf.yaml index 6147d2b1ed..294e7f862e 100644 --- a/tests/components/web_server/test_ota.esp32-idf.yaml +++ b/tests/components/web_server/test_ota.esp32-idf.yaml @@ -14,7 +14,6 @@ packages: # Enable OTA for multipart upload testing ota: - platform: esphome - safe_mode: true password: "test_ota_password" # Web server with OTA enabled 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 db1a181ddd..c7c7574e3b 100644 --- a/tests/components/web_server/test_ota_disabled.esp32-idf.yaml +++ b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml @@ -4,7 +4,6 @@ packages: # OTA is configured but web_server OTA is disabled ota: - platform: esphome - safe_mode: true web_server: port: 8080 From d86f319d66918c0bcb729ad41801fda0f7a9db8c Mon Sep 17 00:00:00 2001 From: lamauny <57617527+lamauny@users.noreply.github.com> Date: Mon, 30 Jun 2025 06:20:36 +0200 Subject: [PATCH 91/93] Add support for LN882X Family (with LibreTiny) (#8954) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/__main__.py | 4 +- esphome/components/api/api_connection.cpp | 2 + esphome/components/async_tcp/__init__.py | 11 +- esphome/components/captive_portal/__init__.py | 11 +- esphome/components/esphome/ota/__init__.py | 1 + esphome/components/libretiny/const.py | 5 + .../libretiny/generate_components.py | 1 + esphome/components/ln882x/__init__.py | 52 ++++ esphome/components/ln882x/boards.py | 285 ++++++++++++++++++ esphome/components/logger/__init__.py | 10 +- .../components/remote_receiver/__init__.py | 1 + esphome/components/sntp/time.py | 2 + esphome/components/socket/__init__.py | 1 + esphome/components/web_server/__init__.py | 12 +- esphome/components/wifi/__init__.py | 1 + esphome/const.py | 1 + esphome/core/__init__.py | 7 +- esphome/dashboard/web_server.py | 10 +- esphome/wizard.py | 19 +- platformio.ini | 13 +- tests/components/adc/test.ln882x-ard.yaml | 4 + .../binary_sensor/test.ln882x-ard.yaml | 2 + tests/components/debug/test.ln882x-ard.yaml | 1 + .../homeassistant/test.ln882x-ard.yaml | 2 + tests/components/script/test.ln882x-ard.yaml | 1 + tests/components/sntp/test.ln882x-ard.yaml | 1 + tests/components/switch/test.ln882x-ard.yaml | 2 + tests/components/syslog/test.ln882x-ard.yaml | 1 + .../components/template/test.ln882x-ard.yaml | 2 + .../build_components_base.ln882x-ard.yaml | 15 + tests/unit_tests/test_config_validation.py | 7 +- tests/unit_tests/test_wizard.py | 22 ++ 33 files changed, 495 insertions(+), 15 deletions(-) create mode 100644 esphome/components/ln882x/__init__.py create mode 100644 esphome/components/ln882x/boards.py create mode 100644 tests/components/adc/test.ln882x-ard.yaml create mode 100644 tests/components/binary_sensor/test.ln882x-ard.yaml create mode 100644 tests/components/debug/test.ln882x-ard.yaml create mode 100644 tests/components/homeassistant/test.ln882x-ard.yaml create mode 100644 tests/components/script/test.ln882x-ard.yaml create mode 100644 tests/components/sntp/test.ln882x-ard.yaml create mode 100644 tests/components/switch/test.ln882x-ard.yaml create mode 100644 tests/components/syslog/test.ln882x-ard.yaml create mode 100644 tests/components/template/test.ln882x-ard.yaml create mode 100644 tests/test_build_components/build_components_base.ln882x-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b3c66c775b..68c8684024 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,6 +248,7 @@ esphome/components/libretiny_pwm/* @kuba2k2 esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz +esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow diff --git a/esphome/__main__.py b/esphome/__main__.py index 2dbdfeb1ff..d8a79c018a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -34,11 +34,9 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, - PLATFORM_RTL87XX, SECRETS_FILES, ) from esphome.core import CORE, EsphomeError, coroutine @@ -354,7 +352,7 @@ def upload_program(config, args, host): if CORE.target_platform in (PLATFORM_RP2040): return upload_using_platformio(config, args.device) - if CORE.target_platform in (PLATFORM_BK72XX, PLATFORM_RTL87XX): + if CORE.is_libretiny: return upload_using_platformio(config, host) return 1 # Unknown target platform diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 6a40f21f99..b7624221c9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1537,6 +1537,8 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { resp.manufacturer = "Raspberry Pi"; #elif defined(USE_BK72XX) resp.manufacturer = "Beken"; +#elif defined(USE_LN882X) + resp.manufacturer = "Lightning"; #elif defined(USE_RTL87XX) resp.manufacturer = "Realtek"; #elif defined(USE_HOST) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index eec6a0e327..29097ce1b6 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -5,6 +5,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority @@ -14,7 +15,15 @@ CODEOWNERS = ["@OttoWinter"] CONFIG_SCHEMA = cv.All( cv.Schema({}), cv.only_with_arduino, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), ) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index a55887948d..cba3b4921a 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority @@ -27,7 +28,15 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), ) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 86006e3e18..901657ec82 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -100,6 +100,7 @@ CONFIG_SCHEMA = ( esp32=3232, rp2040=2040, bk72xx=8892, + ln882x=8820, rtl87xx=8892, ): cv.port, cv.Optional(CONF_PASSWORD): cv.string, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 362609df44..671992f8bd 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -50,6 +50,7 @@ KEY_FAMILY = "family" # COMPONENTS - auto-generated! Do not modify this block. COMPONENT_BK72XX = "bk72xx" +COMPONENT_LN882X = "ln882x" COMPONENT_RTL87XX = "rtl87xx" # COMPONENTS - end @@ -58,6 +59,7 @@ FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" FAMILY_BK7251 = "BK7251" +FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" FAMILY_RTL8720C = "RTL8720C" FAMILIES = [ @@ -65,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231Q, FAMILY_BK7231T, FAMILY_BK7251, + FAMILY_LN882H, FAMILY_RTL8710B, FAMILY_RTL8720C, ] @@ -73,6 +76,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", FAMILY_BK7251: "BK7251", + FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", FAMILY_RTL8720C: "RTL8720C", } @@ -81,6 +85,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, + FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, FAMILY_RTL8720C: COMPONENT_RTL87XX, } diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index ae55fd9e40..c750b79317 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -94,6 +94,7 @@ PIN_SCHEMA_EXTRA = f"libretiny.BASE_PIN_SCHEMA.extend({VAR_PIN_SCHEMA})" COMPONENT_MAP = { "rtl87xx": "realtek-amb", "bk72xx": "beken-72xx", + "ln882x": "lightning-ln882x", } diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py new file mode 100644 index 0000000000..6a76218f87 --- /dev/null +++ b/esphome/components/ln882x/__init__.py @@ -0,0 +1,52 @@ +# This file was auto-generated by libretiny/generate_components.py +# Do not modify its contents. +# For custom pin validators, put validate_pin() or validate_usage() +# in gpio.py file in this directory. +# For changing schema/pin schema, put COMPONENT_SCHEMA or COMPONENT_PIN_SCHEMA +# in schema.py file in this directory. + +from esphome import pins +from esphome.components import libretiny +from esphome.components.libretiny.const import ( + COMPONENT_LN882X, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, + LibreTinyComponent, +) +from esphome.core import CORE + +from .boards import LN882X_BOARD_PINS, LN882X_BOARDS + +CODEOWNERS = ["@lamauny"] +AUTO_LOAD = ["libretiny"] +IS_TARGET_PLATFORM = True + +COMPONENT_DATA = LibreTinyComponent( + name=COMPONENT_LN882X, + boards=LN882X_BOARDS, + board_pins=LN882X_BOARD_PINS, + pin_validation=None, + usage_validation=None, +) + + +def _set_core_data(config): + CORE.data[KEY_LIBRETINY] = {} + CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] = COMPONENT_DATA + return config + + +CONFIG_SCHEMA = libretiny.BASE_SCHEMA + +PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA + +CONFIG_SCHEMA.prepend_extra(_set_core_data) + + +async def to_code(config): + return await libretiny.component_to_code(config) + + +@pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) +async def pin_to_code(config): + return await libretiny.gpio.component_pin_to_code(config) diff --git a/esphome/components/ln882x/boards.py b/esphome/components/ln882x/boards.py new file mode 100644 index 0000000000..43f25994a7 --- /dev/null +++ b/esphome/components/ln882x/boards.py @@ -0,0 +1,285 @@ +# This file was auto-generated by libretiny/generate_components.py +# Do not modify its contents. + +from esphome.components.libretiny.const import FAMILY_LN882H + +LN882X_BOARDS = { + "wl2s": { + "name": "WL2S Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "ln-02": { + "name": "LN-02 Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "generic-ln882hki": { + "name": "Generic - LN882HKI", + "family": FAMILY_LN882H, + }, +} + +LN882X_BOARD_PINS = { + "wl2s": { + "WIRE0_SCL_0": 7, + "WIRE0_SCL_1": 12, + "WIRE0_SCL_2": 3, + "WIRE0_SCL_3": 10, + "WIRE0_SCL_4": 2, + "WIRE0_SCL_5": 0, + "WIRE0_SCL_6": 19, + "WIRE0_SCL_7": 11, + "WIRE0_SCL_8": 9, + "WIRE0_SCL_9": 24, + "WIRE0_SCL_10": 25, + "WIRE0_SCL_11": 5, + "WIRE0_SCL_12": 1, + "WIRE0_SDA_0": 7, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 3, + "WIRE0_SDA_3": 10, + "WIRE0_SDA_4": 2, + "WIRE0_SDA_5": 0, + "WIRE0_SDA_6": 19, + "WIRE0_SDA_7": 11, + "WIRE0_SDA_8": 9, + "WIRE0_SDA_9": 24, + "WIRE0_SDA_10": 25, + "WIRE0_SDA_11": 5, + "WIRE0_SDA_12": 1, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA05": 5, + "PA5": 5, + "PA07": 7, + "PA7": 7, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 1, + "SDA0": 1, + "TX0": 2, + "TX1": 25, + "D0": 7, + "D1": 12, + "D2": 3, + "D3": 10, + "D4": 2, + "D5": 0, + "D6": 19, + "D7": 11, + "D8": 9, + "D9": 24, + "D10": 25, + "D11": 5, + "D12": 1, + "A0": 0, + "A1": 19, + "A2": 1, + }, + "ln-02": { + "WIRE0_SCL_0": 11, + "WIRE0_SCL_1": 19, + "WIRE0_SCL_2": 3, + "WIRE0_SCL_3": 24, + "WIRE0_SCL_4": 2, + "WIRE0_SCL_5": 25, + "WIRE0_SCL_6": 1, + "WIRE0_SCL_7": 0, + "WIRE0_SCL_8": 9, + "WIRE0_SDA_0": 11, + "WIRE0_SDA_1": 19, + "WIRE0_SDA_2": 3, + "WIRE0_SDA_3": 24, + "WIRE0_SDA_4": 2, + "WIRE0_SDA_5": 25, + "WIRE0_SDA_6": 1, + "WIRE0_SDA_7": 0, + "WIRE0_SDA_8": 9, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 9, + "SDA0": 9, + "TX0": 2, + "TX1": 25, + "D0": 11, + "D1": 19, + "D2": 3, + "D3": 24, + "D4": 2, + "D5": 25, + "D6": 1, + "D7": 0, + "D8": 9, + "A0": 19, + "A1": 1, + "A2": 0, + }, + "generic-ln882hki": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 8, + "WIRE0_SCL_9": 9, + "WIRE0_SCL_10": 10, + "WIRE0_SCL_11": 11, + "WIRE0_SCL_12": 12, + "WIRE0_SCL_13": 19, + "WIRE0_SCL_14": 20, + "WIRE0_SCL_15": 21, + "WIRE0_SCL_16": 22, + "WIRE0_SCL_17": 23, + "WIRE0_SCL_18": 24, + "WIRE0_SCL_19": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 8, + "WIRE0_SDA_9": 9, + "WIRE0_SDA_10": 10, + "WIRE0_SDA_11": 11, + "WIRE0_SDA_12": 12, + "WIRE0_SDA_13": 19, + "WIRE0_SDA_14": 20, + "WIRE0_SDA_15": 21, + "WIRE0_SDA_16": 22, + "WIRE0_SDA_17": 23, + "WIRE0_SDA_18": 24, + "WIRE0_SDA_19": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 5, + "D6": 6, + "D7": 7, + "D8": 8, + "D9": 9, + "D10": 10, + "D11": 11, + "D12": 12, + "D13": 19, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "D18": 24, + "D19": 25, + "A2": 0, + "A3": 1, + "A4": 4, + "A5": 19, + "A6": 20, + "A7": 21, + }, +} + +BOARDS = LN882X_BOARDS diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index af62d8a73f..3d4907aa6e 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -16,7 +16,11 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S3, ) from esphome.components.libretiny import get_libretiny_component, get_libretiny_family -from esphome.components.libretiny.const import COMPONENT_BK72XX, COMPONENT_RTL87XX +from esphome.components.libretiny.const import ( + COMPONENT_BK72XX, + COMPONENT_LN882X, + COMPONENT_RTL87XX, +) import esphome.config_validation as cv from esphome.const import ( CONF_ARGS, @@ -35,6 +39,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -100,6 +105,7 @@ UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] UART_SELECTION_LIBRETINY = { COMPONENT_BK72XX: [DEFAULT, UART1, UART2], + COMPONENT_LN882X: [DEFAULT, UART0, UART1, UART2], COMPONENT_RTL87XX: [DEFAULT, UART0, UART1, UART2], } @@ -217,6 +223,7 @@ CONFIG_SCHEMA = cv.All( esp32_p4_idf=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, + ln882x=DEFAULT, rtl87xx=DEFAULT, ): cv.All( cv.only_on( @@ -225,6 +232,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX, + PLATFORM_LN882X, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 321cfc93ff..5de7d8c9c4 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -94,6 +94,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( esp32="10000b", esp8266="1000b", bk72xx="1000b", + ln882x="1000b", rtl87xx="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 6f883d5bed..1c8ee402ad 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -7,6 +7,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -33,6 +34,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, + PLATFORM_LN882X, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 667e30df4b..26031a8da5 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -16,6 +16,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=IMPLEMENTATION_BSD_SOCKETS, rp2040=IMPLEMENTATION_LWIP_TCP, bk72xx=IMPLEMENTATION_LWIP_SOCKETS, + ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, host=IMPLEMENTATION_BSD_SOCKETS, ): cv.one_of( diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 8ff7ce1d16..f2c1824028 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -28,6 +28,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority @@ -180,6 +181,7 @@ CONFIG_SCHEMA = cv.All( esp32_arduino=True, esp32_idf=False, bk72xx=True, + ln882x=True, rtl87xx=True, ): cv.boolean, cv.Optional(CONF_LOG, default=True): cv.boolean, @@ -187,7 +189,15 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), default_url, validate_local, validate_ota, diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 582b826de0..e8ae9b1b4e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -309,6 +309,7 @@ CONFIG_SCHEMA = cv.All( rp2040="light", bk72xx="none", rtl87xx="none", + ln882x="light", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, diff --git a/esphome/const.py b/esphome/const.py index ed6390d8c3..b167935d12 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -12,6 +12,7 @@ PLATFORM_ESP32 = "esp32" PLATFORM_ESP8266 = "esp8266" PLATFORM_HOST = "host" PLATFORM_LIBRETINY_OLDSTYLE = "libretiny" +PLATFORM_LN882X = "ln882x" PLATFORM_RP2040 = "rp2040" PLATFORM_RTL87XX = "rtl87xx" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 368e2affe9..e33bbcf726 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -20,6 +20,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_HOST, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -661,9 +662,13 @@ class EsphomeCore: def is_rtl87xx(self): return self.target_platform == PLATFORM_RTL87XX + @property + def is_ln882x(self): + return self.target_platform == PLATFORM_LN882X + @property def is_libretiny(self): - return self.is_bk72xx or self.is_rtl87xx + return self.is_bk72xx or self.is_rtl87xx or self.is_ln882x @property def is_host(self): diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 529a0815b8..480285b6c1 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -639,7 +639,11 @@ class DownloadListRequestHandler(BaseHandler): if platform.upper() in ESP32_VARIANTS: platform = "esp32" - elif platform in (const.PLATFORM_RTL87XX, const.PLATFORM_BK72XX): + elif platform in ( + const.PLATFORM_RTL87XX, + const.PLATFORM_BK72XX, + const.PLATFORM_LN882X, + ): platform = "libretiny" try: @@ -837,6 +841,10 @@ class BoardsRequestHandler(BaseHandler): from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS boards = BK72XX_BOARDS + elif platform == const.PLATFORM_LN882X: + from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS + + boards = LN882X_BOARDS elif platform == const.PLATFORM_RTL87XX: from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS diff --git a/esphome/wizard.py b/esphome/wizard.py index 7b4d87be63..1826487aa4 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -83,6 +83,11 @@ bk72xx: board: {board} """ +LN882X_CONFIG = """ +ln882x: + board: {board} +""" + RTL87XX_CONFIG = """ rtl87xx: board: {board} @@ -93,6 +98,7 @@ HARDWARE_BASE_CONFIGS = { "ESP32": ESP32_CONFIG, "RP2040": RP2040_CONFIG, "BK72XX": BK72XX_CONFIG, + "LN882X": LN882X_CONFIG, "RTL87XX": RTL87XX_CONFIG, } @@ -157,7 +163,7 @@ def wizard_file(**kwargs): """ # pylint: disable=consider-using-f-string - if kwargs["platform"] in ["ESP8266", "ESP32", "BK72XX", "RTL87XX"]: + if kwargs["platform"] in ["ESP8266", "ESP32", "BK72XX", "LN882X", "RTL87XX"]: config += """ # Enable fallback hotspot (captive portal) in case wifi connection fails ap: @@ -181,6 +187,7 @@ def wizard_write(path, **kwargs): from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards + from esphome.components.ln882x import boards as ln882x_boards from esphome.components.rp2040 import boards as rp2040_boards from esphome.components.rtl87xx import boards as rtl87xx_boards @@ -200,6 +207,8 @@ def wizard_write(path, **kwargs): platform = "RP2040" elif board in bk72xx_boards.BOARDS: platform = "BK72XX" + elif board in ln882x_boards.BOARDS: + platform = "LN882X" elif board in rtl87xx_boards.BOARDS: platform = "RTL87XX" else: @@ -253,6 +262,7 @@ def wizard(path): from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards + from esphome.components.ln882x import boards as ln882x_boards from esphome.components.rp2040 import boards as rp2040_boards from esphome.components.rtl87xx import boards as rtl87xx_boards @@ -325,7 +335,7 @@ def wizard(path): "firmwares for it." ) - wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "RTL87XX", "RP2040"] + wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"] safe_print( "Please choose one of the supported microcontrollers " "(Use ESP8266 for Sonoff devices)." @@ -361,7 +371,7 @@ def wizard(path): board_link = ( "https://www.raspberrypi.com/documentation/microcontrollers/rp2040.html" ) - elif platform in ["BK72XX", "RTL87XX"]: + elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" else: raise NotImplementedError("Unknown platform!") @@ -384,6 +394,9 @@ def wizard(path): elif platform == "BK72XX": safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".') boards_list = bk72xx_boards.BOARDS.items() + elif platform == "LN882X": + safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".') + boards_list = ln882x_boards.BOARDS.items() elif platform == "RTL87XX": safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".') boards_list = rtl87xx_boards.BOARDS.items() diff --git a/platformio.ini b/platformio.ini index be9d7587c2..79e22f90b0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -4,7 +4,7 @@ ; It's *not* used during runtime. [platformio] -default_envs = esp8266-arduino, esp32-arduino, esp32-idf, bk72xx-arduino +default_envs = esp8266-arduino, esp32-arduino, esp32-idf, bk72xx-arduino, ln882h-arduino ; Ideally, we want src_dir to be the root directory of the repository, to mimic the runtime build ; environment as best as possible. Unfortunately, the ESP-IDF toolchain really doesn't like this ; being the root directory. Instead, set esphome/ as the source directory, all our sources are in @@ -530,6 +530,17 @@ build_flags = build_unflags = ${common.build_unflags} +[env:ln882h-arduino] +extends = common:libretiny-arduino +board = generic-ln882hki +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:runtime.build_flags} + -DUSE_LN882X + -DUSE_LIBRETINY_VARIANT_LN882H +build_unflags = + ${common.build_unflags} + [env:rtl87xxb-arduino] extends = common:libretiny-arduino board = generic-rtl8710bn-2mb-788k diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..92c76ca9b3 --- /dev/null +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -0,0 +1,4 @@ +sensor: + - platform: adc + pin: PA0 + name: Basic ADC Test diff --git a/tests/components/binary_sensor/test.ln882x-ard.yaml b/tests/components/binary_sensor/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/binary_sensor/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/debug/test.ln882x-ard.yaml b/tests/components/debug/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/homeassistant/test.ln882x-ard.yaml b/tests/components/homeassistant/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/homeassistant/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/script/test.ln882x-ard.yaml b/tests/components/script/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/script/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sntp/test.ln882x-ard.yaml b/tests/components/sntp/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sntp/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/switch/test.ln882x-ard.yaml b/tests/components/switch/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/switch/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/syslog/test.ln882x-ard.yaml b/tests/components/syslog/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/syslog/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/template/test.ln882x-ard.yaml b/tests/components/template/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/template/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/test_build_components/build_components_base.ln882x-ard.yaml b/tests/test_build_components/build_components_base.ln882x-ard.yaml new file mode 100644 index 0000000000..80fc6690f9 --- /dev/null +++ b/tests/test_build_components/build_components_base.ln882x-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestespln882x + friendly_name: $component_name + +ln882x: + board: generic-ln882hki + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7a1354589c..2928c5c83a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -20,6 +20,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_HOST, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -214,7 +215,8 @@ def hex_int__valid(value): ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), - ("host", PLATFORM_HOST, None, "23", "23", "23", "23"), + ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), + ("host", PLATFORM_HOST, None, "24", "24", "24", "24"), ], ) def test_split_default(framework, platform, variant, full, idf, arduino, simple): @@ -244,7 +246,8 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "rp2040": "20", "bk72xx": "21", "rtl87xx": "22", - "host": "23", + "ln882x": "23", + "host": "24", } idf_mappings = { diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 6d360740f4..ab20b2abb5 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -8,6 +8,7 @@ import pytest from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS from esphome.components.esp8266.boards import ESP8266_BOARD_PINS +from esphome.components.ln882x.boards import LN882X_BOARD_PINS from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS from esphome.core import CORE import esphome.wizard as wz @@ -187,6 +188,27 @@ def test_wizard_write_defaults_platform_from_board_bk72xx( assert "bk72xx:" in generated_config +def test_wizard_write_defaults_platform_from_board_ln882x( + default_config, tmp_path, monkeypatch +): + """ + If the platform is not explicitly set, use "LN882X" if the board is one of LN882X boards + """ + # Given + del default_config["platform"] + default_config["board"] = [*LN882X_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "ln882x:" in generated_config + + def test_wizard_write_defaults_platform_from_board_rtl87xx( default_config, tmp_path, monkeypatch ): From 087697106c4685a635ae82f809ec19135fb10e02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 23:32:59 -0500 Subject: [PATCH 92/93] remove debug --- esphome/components/web_server_idf/multipart.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 7944ad4e5d..17945c1d23 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -253,9 +253,6 @@ bool parse_multipart_boundary(const char *content_type, const char **boundary_st *boundary_start = start; - // Debug log the extracted boundary - ESP_LOGV("multipart_utils", "Extracted boundary: '%.*s' (len: %zu)", (int) *boundary_len, start, *boundary_len); - return true; } From 7dc093815fe75a4d8dd99d193ecc551eb6c2170a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jun 2025 23:40:09 -0500 Subject: [PATCH 93/93] reduce --- .../components/web_server_idf/multipart.cpp | 23 +++---------------- esphome/components/web_server_idf/multipart.h | 3 --- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 17945c1d23..8655226ab9 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -115,24 +115,6 @@ bool str_startswith_case_insensitive(const std::string &str, const std::string & return str_ncmp_ci(str.c_str(), prefix.c_str(), prefix.length()); } -// Find a substring case-insensitively -size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos) { - if (needle.empty() || pos >= haystack.length()) { - return std::string::npos; - } - - const size_t needle_len = needle.length(); - const size_t max_pos = haystack.length() - needle_len; - - for (size_t i = pos; i <= max_pos; i++) { - if (str_ncmp_ci(haystack.c_str() + i, needle.c_str(), needle_len)) { - return i; - } - } - - return std::string::npos; -} - // Extract a parameter value from a header line // Handles both quoted and unquoted values std::string extract_header_param(const std::string &header, const std::string ¶m) { @@ -140,10 +122,11 @@ std::string extract_header_param(const std::string &header, const std::string &p while (search_pos < header.length()) { // Look for param name - size_t pos = str_find_case_insensitive(header, param, search_pos); - if (pos == std::string::npos) { + const char *found = stristr(header.c_str() + search_pos, param.c_str()); + if (!found) { return ""; } + size_t pos = found - header.c_str(); // Check if this is a word boundary (not part of another parameter) if (pos > 0 && header[pos - 1] != ' ' && header[pos - 1] != ';' && header[pos - 1] != '\t') { diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index 3edb61978a..073e1e7c2b 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -68,9 +68,6 @@ class MultipartReader { // Case-insensitive string prefix check bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix); -// Find a substring case-insensitively -size_t str_find_case_insensitive(const std::string &haystack, const std::string &needle, size_t pos = 0); - // Extract a parameter value from a header line // Handles both quoted and unquoted values std::string extract_header_param(const std::string &header, const std::string ¶m);