Replace custom OTA implementation in web_server_base (#9274)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
J. Nick Koston 2025-07-01 20:50:45 -05:00 committed by GitHub
parent 03566c34ed
commit 84ab758b22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 776 additions and 385 deletions

View File

@ -498,6 +498,7 @@ esphome/components/voice_assistant/* @jesserockz @kahrendt
esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher
esphome/components/waveshare_epaper/* @clydebarrow
esphome/components/web_server/ota/* @esphome/core
esphome/components/web_server_base/* @OttoWinter
esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic

View File

@ -12,7 +12,7 @@ from esphome.const import (
)
from esphome.core import CORE, coroutine_with_priority
AUTO_LOAD = ["web_server_base"]
AUTO_LOAD = ["web_server_base", "ota.web_server"]
DEPENDENCIES = ["wifi"]
CODEOWNERS = ["@OttoWinter"]

View File

@ -47,9 +47,6 @@ void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
this->base_->add_handler(this);
#ifdef USE_WEBSERVER_OTA
this->base_->add_ota_handler();
#endif
}
#ifdef USE_ARDUINO

View File

@ -67,7 +67,28 @@ class OTAComponent : public Component {
}
protected:
CallbackManager<void(ota::OTAState, float, uint8_t)> state_callback_{};
/** Extended callback manager with deferred call support.
*
* This adds a call_deferred() method for thread-safe execution from other tasks.
*/
class StateCallbackManager : public CallbackManager<void(OTAState, float, uint8_t)> {
public:
StateCallbackManager(OTAComponent *component) : component_(component) {}
/** Call callbacks with deferral to main loop (for thread safety).
*
* This should be used by OTA implementations that run in separate tasks
* (like web_server OTA) to ensure callbacks execute in the main loop.
*/
void call_deferred(ota::OTAState state, float progress, uint8_t error) {
component_->defer([this, state, progress, error]() { this->call(state, progress, error); });
}
private:
OTAComponent *component_;
};
StateCallbackManager state_callback_{this};
#endif
};
@ -89,6 +110,11 @@ class OTAGlobalCallback {
OTAGlobalCallback *get_global_ota_callback();
void register_ota_platform(OTAComponent *ota_caller);
// OTA implementations should use:
// - state_callback_.call() when already in main loop (e.g., esphome OTA)
// - state_callback_.call_deferred() when in separate task (e.g., web_server OTA)
// This ensures proper callback execution in all contexts.
#endif
std::unique_ptr<ota::OTABackend> make_ota_backend();

View File

@ -15,6 +15,11 @@ static const char *const TAG = "ota.arduino_esp32";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ArduinoESP32OTABackend>(); }
OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) {
// Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA
// where the exact firmware size is unknown due to multipart encoding
if (image_size == 0) {
image_size = UPDATE_SIZE_UNKNOWN;
}
bool ret = Update.begin(image_size, U_FLASH);
if (ret) {
return OTA_RESPONSE_OK;
@ -29,7 +34,10 @@ OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) {
return OTA_RESPONSE_ERROR_UNKNOWN;
}
void ArduinoESP32OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); }
void ArduinoESP32OTABackend::set_update_md5(const char *md5) {
Update.setMD5(md5);
this->md5_set_ = true;
}
OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) {
size_t written = Update.write(data, len);
@ -44,7 +52,9 @@ OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) {
}
OTAResponseTypes ArduinoESP32OTABackend::end() {
if (Update.end()) {
// Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5
// This matches the behavior of the old web_server OTA implementation
if (Update.end(!this->md5_set_)) {
return OTA_RESPONSE_OK;
}

View File

@ -16,6 +16,9 @@ class ArduinoESP32OTABackend : public OTABackend {
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
private:
bool md5_set_{false};
};
} // namespace ota

View File

@ -17,6 +17,11 @@ static const char *const TAG = "ota.arduino_esp8266";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ArduinoESP8266OTABackend>(); }
OTAResponseTypes ArduinoESP8266OTABackend::begin(size_t image_size) {
// Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space
if (image_size == 0) {
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
image_size = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
}
bool ret = Update.begin(image_size, U_FLASH);
if (ret) {
esp8266::preferences_prevent_write(true);
@ -38,7 +43,10 @@ OTAResponseTypes ArduinoESP8266OTABackend::begin(size_t image_size) {
return OTA_RESPONSE_ERROR_UNKNOWN;
}
void ArduinoESP8266OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); }
void ArduinoESP8266OTABackend::set_update_md5(const char *md5) {
Update.setMD5(md5);
this->md5_set_ = true;
}
OTAResponseTypes ArduinoESP8266OTABackend::write(uint8_t *data, size_t len) {
size_t written = Update.write(data, len);
@ -53,13 +61,19 @@ OTAResponseTypes ArduinoESP8266OTABackend::write(uint8_t *data, size_t len) {
}
OTAResponseTypes ArduinoESP8266OTABackend::end() {
if (Update.end()) {
// Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5
// This matches the behavior of the old web_server OTA implementation
bool success = Update.end(!this->md5_set_);
// On ESP8266, Update.end() might return false even with error code 0
// Check the actual error code to determine success
uint8_t error = Update.getError();
if (success || error == UPDATE_ERROR_OK) {
return OTA_RESPONSE_OK;
}
uint8_t error = Update.getError();
ESP_LOGE(TAG, "End error: %d", error);
return OTA_RESPONSE_ERROR_UPDATE_END;
}

View File

@ -21,6 +21,9 @@ class ArduinoESP8266OTABackend : public OTABackend {
#else
bool supports_compression() override { return false; }
#endif
private:
bool md5_set_{false};
};
} // namespace ota

View File

@ -15,6 +15,11 @@ static const char *const TAG = "ota.arduino_libretiny";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ArduinoLibreTinyOTABackend>(); }
OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) {
// Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA
// where the exact firmware size is unknown due to multipart encoding
if (image_size == 0) {
image_size = UPDATE_SIZE_UNKNOWN;
}
bool ret = Update.begin(image_size, U_FLASH);
if (ret) {
return OTA_RESPONSE_OK;
@ -29,7 +34,10 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) {
return OTA_RESPONSE_ERROR_UNKNOWN;
}
void ArduinoLibreTinyOTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); }
void ArduinoLibreTinyOTABackend::set_update_md5(const char *md5) {
Update.setMD5(md5);
this->md5_set_ = true;
}
OTAResponseTypes ArduinoLibreTinyOTABackend::write(uint8_t *data, size_t len) {
size_t written = Update.write(data, len);
@ -44,7 +52,9 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::write(uint8_t *data, size_t len) {
}
OTAResponseTypes ArduinoLibreTinyOTABackend::end() {
if (Update.end()) {
// Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5
// This matches the behavior of the old web_server OTA implementation
if (Update.end(!this->md5_set_)) {
return OTA_RESPONSE_OK;
}

View File

@ -15,6 +15,9 @@ class ArduinoLibreTinyOTABackend : public OTABackend {
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
private:
bool md5_set_{false};
};
} // namespace ota

View File

@ -17,6 +17,8 @@ static const char *const TAG = "ota.arduino_rp2040";
std::unique_ptr<ota::OTABackend> make_ota_backend() { return make_unique<ota::ArduinoRP2040OTABackend>(); }
OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) {
// OTA size of 0 is not currently handled, but
// web_server is not supported for RP2040, so this is not an issue.
bool ret = Update.begin(image_size, U_FLASH);
if (ret) {
rp2040::preferences_prevent_write(true);
@ -38,7 +40,10 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) {
return OTA_RESPONSE_ERROR_UNKNOWN;
}
void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); }
void ArduinoRP2040OTABackend::set_update_md5(const char *md5) {
Update.setMD5(md5);
this->md5_set_ = true;
}
OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) {
size_t written = Update.write(data, len);
@ -53,7 +58,9 @@ OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) {
}
OTAResponseTypes ArduinoRP2040OTABackend::end() {
if (Update.end()) {
// Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5
// This matches the behavior of the old web_server OTA implementation
if (Update.end(!this->md5_set_)) {
return OTA_RESPONSE_OK;
}

View File

@ -17,6 +17,9 @@ class ArduinoRP2040OTABackend : public OTABackend {
OTAResponseTypes end() override;
void abort() override;
bool supports_compression() override { return false; }
private:
bool md5_set_{false};
};
} // namespace ota

View File

@ -56,7 +56,10 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size) {
return OTA_RESPONSE_OK;
}
void IDFOTABackend::set_update_md5(const char *expected_md5) { memcpy(this->expected_bin_md5_, expected_md5, 32); }
void IDFOTABackend::set_update_md5(const char *expected_md5) {
memcpy(this->expected_bin_md5_, expected_md5, 32);
this->md5_set_ = true;
}
OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
esp_err_t err = esp_ota_write(this->update_handle_, data, len);
@ -73,10 +76,12 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
}
OTAResponseTypes IDFOTABackend::end() {
this->md5_.calculate();
if (!this->md5_.equals_hex(this->expected_bin_md5_)) {
this->abort();
return OTA_RESPONSE_ERROR_MD5_MISMATCH;
if (this->md5_set_) {
this->md5_.calculate();
if (!this->md5_.equals_hex(this->expected_bin_md5_)) {
this->abort();
return OTA_RESPONSE_ERROR_MD5_MISMATCH;
}
}
esp_err_t err = esp_ota_end(this->update_handle_);
this->update_handle_ = 0;

View File

@ -24,6 +24,7 @@ class IDFOTABackend : public OTABackend {
const esp_partition_t *partition_;
md5::MD5Digest md5_{};
char expected_bin_md5_[32];
bool md5_set_{false};
};
} // namespace ota

View File

@ -33,6 +33,7 @@ from esphome.const import (
)
from esphome.core import CORE, coroutine_with_priority
import esphome.final_validate as fv
from esphome.types import ConfigType
AUTO_LOAD = ["json", "web_server_base"]
@ -47,7 +48,7 @@ WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller)
sorting_groups = {}
def default_url(config):
def default_url(config: ConfigType) -> ConfigType:
config = config.copy()
if config[CONF_VERSION] == 1:
if CONF_CSS_URL not in config:
@ -67,13 +68,27 @@ def default_url(config):
return config
def validate_local(config):
def validate_local(config: ConfigType) -> ConfigType:
if CONF_LOCAL in config and config[CONF_VERSION] == 1:
raise cv.Invalid("'local' is not supported in version 1")
return config
def validate_sorting_groups(config):
def validate_ota_removed(config: ConfigType) -> ConfigType:
# Only raise error if OTA is explicitly enabled (True)
# If it's False or not specified, we can safely ignore it
if config.get(CONF_OTA):
raise cv.Invalid(
f"The '{CONF_OTA}' option has been removed from 'web_server'. "
f"Please use the new OTA platform structure instead:\n\n"
f"ota:\n"
f" - platform: web_server\n\n"
f"See https://esphome.io/components/ota for more information."
)
return config
def validate_sorting_groups(config: ConfigType) -> ConfigType:
if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3:
raise cv.Invalid(
f"'{CONF_SORTING_GROUPS}' is only supported in 'web_server' version 3"
@ -84,7 +99,7 @@ def validate_sorting_groups(config):
def _validate_no_sorting_component(
sorting_component: str,
webserver_version: int,
config: dict,
config: ConfigType,
path: list[str] | None = None,
) -> None:
if path is None:
@ -107,7 +122,7 @@ def _validate_no_sorting_component(
)
def _final_validate_sorting(config):
def _final_validate_sorting(config: ConfigType) -> ConfigType:
if (webserver_version := config.get(CONF_VERSION)) != 3:
_validate_no_sorting_component(
CONF_SORTING_WEIGHT, webserver_version, fv.full_config.get()
@ -170,7 +185,7 @@ CONFIG_SCHEMA = cv.All(
web_server_base.WebServerBase
),
cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean,
cv.Optional(CONF_OTA, default=True): cv.boolean,
cv.Optional(CONF_OTA, default=False): cv.boolean,
cv.Optional(CONF_LOG, default=True): cv.boolean,
cv.Optional(CONF_LOCAL): cv.boolean,
cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group),
@ -188,6 +203,7 @@ CONFIG_SCHEMA = cv.All(
default_url,
validate_local,
validate_sorting_groups,
validate_ota_removed,
)
@ -271,11 +287,8 @@ async def to_code(config):
else:
cg.add(var.set_css_url(config[CONF_CSS_URL]))
cg.add(var.set_js_url(config[CONF_JS_URL]))
cg.add(var.set_allow_ota(config[CONF_OTA]))
if config[CONF_OTA]:
# Define USE_WEBSERVER_OTA based only on web_server OTA config
# This allows web server OTA to work without loading the OTA component
cg.add_define("USE_WEBSERVER_OTA")
# OTA is now handled by the web_server OTA platform
# The CONF_OTA option is kept only for backwards compatibility validation
cg.add(var.set_expose_log(config[CONF_LOG]))
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")

View File

@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network", "web_server_base"]
web_server_ns = cg.esphome_ns.namespace("web_server")
WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(WebServerOTAComponent),
}
)
.extend(BASE_OTA_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
@coroutine_with_priority(52.0)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await ota_to_code(var, config)
await cg.register_component(var, config)
cg.add_define("USE_WEBSERVER_OTA")
if CORE.using_esp_idf:
add_idf_component(name="zorxx/multipart-parser", ref="1.0.1")

View File

@ -0,0 +1,210 @@
#include "ota_web_server.h"
#ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#ifdef USE_ARDUINO
#ifdef USE_ESP8266
#include <Updater.h>
#elif defined(USE_ESP32) || defined(USE_LIBRETINY)
#include <Update.h>
#endif
#endif // USE_ARDUINO
namespace esphome {
namespace web_server {
static const char *const TAG = "web_server.ota";
class OTARequestHandler : public AsyncWebHandler {
public:
OTARequestHandler(WebServerOTAComponent *parent) : parent_(parent) {}
void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
bool final) override;
bool canHandle(AsyncWebServerRequest *request) const override {
return request->url() == "/update" && request->method() == HTTP_POST;
}
// NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() const override { return false; }
protected:
void report_ota_progress_(AsyncWebServerRequest *request);
void schedule_ota_reboot_();
void ota_init_(const char *filename);
uint32_t last_ota_progress_{0};
uint32_t ota_read_length_{0};
WebServerOTAComponent *parent_;
bool ota_success_{false};
private:
std::unique_ptr<ota::OTABackend> ota_backend_{nullptr};
};
void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
const uint32_t now = millis();
if (now - this->last_ota_progress_ > 1000) {
float percentage = 0.0f;
if (request->contentLength() != 0) {
// Note: Using contentLength() for progress calculation is technically wrong as it includes
// multipart headers/boundaries, but it's only off by a small amount and we don't have
// access to the actual firmware size until the upload is complete. This is intentional
// as it still gives the user a reasonable progress indication.
percentage = (this->ota_read_length_ * 100.0f) / request->contentLength();
ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage);
} else {
ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_);
}
#ifdef USE_OTA_STATE_CALLBACK
// Report progress - use call_deferred since we're in web server task
this->parent_->state_callback_.call_deferred(ota::OTA_IN_PROGRESS, percentage, 0);
#endif
this->last_ota_progress_ = now;
}
}
void OTARequestHandler::schedule_ota_reboot_() {
ESP_LOGI(TAG, "OTA update successful!");
this->parent_->set_timeout(100, []() {
ESP_LOGI(TAG, "Performing OTA reboot now");
App.safe_reboot();
});
}
void OTARequestHandler::ota_init_(const char *filename) {
ESP_LOGI(TAG, "OTA Update Start: %s", filename);
this->ota_read_length_ = 0;
this->ota_success_ = false;
}
void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index,
uint8_t *data, size_t len, bool final) {
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
if (index == 0 && !this->ota_backend_) {
// Initialize OTA on first call
this->ota_init_(filename.c_str());
#ifdef USE_OTA_STATE_CALLBACK
// Notify OTA started - use call_deferred since we're in web server task
this->parent_->state_callback_.call_deferred(ota::OTA_STARTED, 0.0f, 0);
#endif
// Platform-specific pre-initialization
#ifdef USE_ARDUINO
#ifdef USE_ESP8266
Update.runAsync(true);
#endif
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
if (Update.isRunning()) {
Update.abort();
}
#endif
#endif // USE_ARDUINO
this->ota_backend_ = ota::make_ota_backend();
if (!this->ota_backend_) {
ESP_LOGE(TAG, "Failed to create OTA backend");
#ifdef USE_OTA_STATE_CALLBACK
this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f,
static_cast<uint8_t>(ota::OTA_RESPONSE_ERROR_UNKNOWN));
#endif
return;
}
// Web server OTA uses multipart uploads where the actual firmware size
// is unknown (contentLength includes multipart overhead)
// Pass 0 to indicate unknown size
error_code = this->ota_backend_->begin(0);
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGE(TAG, "OTA begin failed: %d", error_code);
this->ota_backend_.reset();
#ifdef USE_OTA_STATE_CALLBACK
this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif
return;
}
}
if (!this->ota_backend_) {
return;
}
// Process data
if (len > 0) {
error_code = this->ota_backend_->write(data, len);
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGE(TAG, "OTA write failed: %d", error_code);
this->ota_backend_->abort();
this->ota_backend_.reset();
#ifdef USE_OTA_STATE_CALLBACK
this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif
return;
}
this->ota_read_length_ += len;
this->report_ota_progress_(request);
}
// Finalize
if (final) {
ESP_LOGD(TAG, "OTA final chunk: index=%u, len=%u, total_read=%u, contentLength=%u", index, len,
this->ota_read_length_, request->contentLength());
// For Arduino framework, the Update library tracks expected size from firmware header
// If we haven't received enough data, calling end() will fail
// This can happen if the upload is interrupted or the client disconnects
error_code = this->ota_backend_->end();
if (error_code == ota::OTA_RESPONSE_OK) {
this->ota_success_ = true;
#ifdef USE_OTA_STATE_CALLBACK
// Report completion before reboot - use call_deferred since we're in web server task
this->parent_->state_callback_.call_deferred(ota::OTA_COMPLETED, 100.0f, 0);
#endif
this->schedule_ota_reboot_();
} else {
ESP_LOGE(TAG, "OTA end failed: %d", error_code);
#ifdef USE_OTA_STATE_CALLBACK
this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast<uint8_t>(error_code));
#endif
}
this->ota_backend_.reset();
}
}
void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) {
AsyncWebServerResponse *response;
// Use the ota_success_ flag to determine the actual result
const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!";
response = request->beginResponse(200, "text/plain", msg);
response->addHeader("Connection", "close");
request->send(response);
}
void WebServerOTAComponent::setup() {
// Get the global web server base instance and register our handler
auto *base = web_server_base::global_web_server_base;
if (base == nullptr) {
ESP_LOGE(TAG, "WebServerBase not found");
this->mark_failed();
return;
}
// AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed
base->add_handler(new OTARequestHandler(this)); // NOLINT
#ifdef USE_OTA_STATE_CALLBACK
// Register with global OTA callback system
ota::register_ota_platform(this);
#endif
}
void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); }
} // namespace web_server
} // namespace esphome
#endif // USE_WEBSERVER_OTA

View File

@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_WEBSERVER_OTA
#include "esphome/components/ota/ota_backend.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/core/component.h"
namespace esphome {
namespace web_server {
class WebServerOTAComponent : public ota::OTAComponent {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
protected:
friend class OTARequestHandler;
};
} // namespace web_server
} // namespace esphome
#endif // USE_WEBSERVER_OTA

View File

@ -273,7 +273,11 @@ std::string WebServer::get_config_json() {
return json::build_json([this](JsonObject root) {
root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name();
root["comment"] = App.get_comment();
root["ota"] = this->allow_ota_;
#ifdef USE_WEBSERVER_OTA
root["ota"] = true; // web_server OTA platform is configured
#else
root["ota"] = false;
#endif
root["log"] = this->expose_log_;
root["lang"] = "en";
});
@ -299,10 +303,7 @@ void WebServer::setup() {
#endif
this->base_->add_handler(this);
#ifdef USE_WEBSERVER_OTA
if (this->allow_ota_)
this->base_->add_ota_handler();
#endif
// OTA is now handled by the web_server OTA platform
// doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly
// getting a lot of events

View File

@ -212,11 +212,6 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
* @param include_internal Whether internal components should be displayed.
*/
void set_include_internal(bool include_internal) { include_internal_ = include_internal; }
/** Set whether or not the webserver should expose the OTA form and handler.
*
* @param allow_ota.
*/
void set_allow_ota(bool allow_ota) { this->allow_ota_ = allow_ota; }
/** Set whether or not the webserver should expose the Log.
*
* @param expose_log.
@ -525,7 +520,6 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
#ifdef USE_WEBSERVER_JS_INCLUDE
const char *js_include_{nullptr};
#endif
bool allow_ota_{true};
bool expose_log_{true};
#ifdef USE_ESP32
std::deque<std::function<void()>> to_schedule_;

View File

@ -192,11 +192,10 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
stream->print(F("</tbody></table><p>See <a href=\"https://esphome.io/web-api/index.html\">ESPHome Web API</a> for "
"REST API documentation.</p>"));
if (this->allow_ota_) {
stream->print(
F("<h2>OTA Update</h2><form method=\"POST\" action=\"/update\" enctype=\"multipart/form-data\"><input "
"type=\"file\" name=\"update\"><input type=\"submit\" value=\"Update\"></form>"));
}
#ifdef USE_WEBSERVER_OTA
stream->print(F("<h2>OTA Update</h2><form method=\"POST\" action=\"/update\" enctype=\"multipart/form-data\"><input "
"type=\"file\" name=\"update\"><input type=\"submit\" value=\"Update\"></form>"));
#endif
stream->print(F("<h2>Debug Log</h2><pre id=\"log\"></pre>"));
#ifdef USE_WEBSERVER_JS_INCLUDE
if (this->js_include_ != nullptr) {

View File

@ -30,6 +30,7 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(cg.RawExpression(f"{web_server_base_ns}::global_web_server_base = {var}"))
if CORE.using_arduino:
if CORE.is_esp32:

View File

@ -4,123 +4,12 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ARDUINO
#include <StreamString.h>
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
#include <Update.h>
#endif
#ifdef USE_ESP8266
#include <Updater.h>
#endif
#endif
#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA)
#include <esp_ota_ops.h>
#include <esp_task_wdt.h>
#endif
namespace esphome {
namespace web_server_base {
static const char *const TAG = "web_server_base";
#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA)
// Minimal OTA backend implementation for web server
// This allows OTA updates via web server without requiring the OTA component
// TODO: In the future, this should be refactored into a common ota_base component
// that both web_server and ota components can depend on, avoiding code duplication
// while keeping the components independent. This would allow both ESP-IDF and Arduino
// implementations to share the base OTA functionality without requiring the full OTA component.
// The IDFWebServerOTABackend class is intentionally designed with the same interface
// as OTABackend to make it easy to swap to using OTABackend when the ota component
// is split into ota and ota_base in the future.
class IDFWebServerOTABackend {
public:
bool begin() {
this->partition_ = esp_ota_get_next_update_partition(nullptr);
if (this->partition_ == nullptr) {
ESP_LOGE(TAG, "No OTA partition available");
return false;
}
#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15
// The following function takes longer than the default timeout of WDT due to flash erase
#if ESP_IDF_VERSION_MAJOR >= 5
esp_task_wdt_config_t wdtc;
wdtc.idle_core_mask = 0;
#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0
wdtc.idle_core_mask |= (1 << 0);
#endif
#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1
wdtc.idle_core_mask |= (1 << 1);
#endif
wdtc.timeout_ms = 15000;
wdtc.trigger_panic = false;
esp_task_wdt_reconfigure(&wdtc);
#else
esp_task_wdt_init(15, false);
#endif
#endif
esp_err_t err = esp_ota_begin(this->partition_, 0, &this->update_handle_);
#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15
// Set the WDT back to the configured timeout
#if ESP_IDF_VERSION_MAJOR >= 5
wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000;
esp_task_wdt_reconfigure(&wdtc);
#else
esp_task_wdt_init(CONFIG_ESP_TASK_WDT_TIMEOUT_S, false);
#endif
#endif
if (err != ESP_OK) {
esp_ota_abort(this->update_handle_);
this->update_handle_ = 0;
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
return false;
}
return true;
}
bool write(uint8_t *data, size_t len) {
esp_err_t err = esp_ota_write(this->update_handle_, data, len);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_write failed: %s", esp_err_to_name(err));
return false;
}
return true;
}
bool end() {
esp_err_t err = esp_ota_end(this->update_handle_);
this->update_handle_ = 0;
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
return false;
}
err = esp_ota_set_boot_partition(this->partition_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
return false;
}
return true;
}
void abort() {
if (this->update_handle_ != 0) {
esp_ota_abort(this->update_handle_);
this->update_handle_ = 0;
}
}
private:
esp_ota_handle_t update_handle_{0};
const esp_partition_t *partition_{nullptr};
};
#endif
WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void WebServerBase::add_handler(AsyncWebHandler *handler) {
// remove all handlers
@ -134,157 +23,6 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) {
}
}
#ifdef USE_WEBSERVER_OTA
void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
const uint32_t now = millis();
if (now - this->last_ota_progress_ > 1000) {
if (request->contentLength() != 0) {
float percentage = (this->ota_read_length_ * 100.0f) / request->contentLength();
ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage);
} else {
ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_);
}
this->last_ota_progress_ = now;
}
}
void OTARequestHandler::schedule_ota_reboot_() {
ESP_LOGI(TAG, "OTA update successful!");
this->parent_->set_timeout(100, []() {
ESP_LOGI(TAG, "Performing OTA reboot now");
App.safe_reboot();
});
}
void OTARequestHandler::ota_init_(const char *filename) {
ESP_LOGI(TAG, "OTA Update Start: %s", filename);
this->ota_read_length_ = 0;
}
void report_ota_error() {
#ifdef USE_ARDUINO
StreamString ss;
Update.printError(ss);
ESP_LOGW(TAG, "OTA Update failed! Error: %s", ss.c_str());
#endif
}
void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index,
uint8_t *data, size_t len, bool final) {
#ifdef USE_ARDUINO
bool success;
if (index == 0) {
this->ota_init_(filename.c_str());
#ifdef USE_ESP8266
Update.runAsync(true);
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
success = Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000);
#endif
#if defined(USE_ESP32_FRAMEWORK_ARDUINO) || defined(USE_LIBRETINY)
if (Update.isRunning()) {
Update.abort();
}
success = Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH);
#endif
if (!success) {
report_ota_error();
return;
}
} else if (Update.hasError()) {
// don't spam logs with errors if something failed at start
return;
}
success = Update.write(data, len) == len;
if (!success) {
report_ota_error();
return;
}
this->ota_read_length_ += len;
this->report_ota_progress_(request);
if (final) {
if (Update.end(true)) {
this->schedule_ota_reboot_();
} else {
report_ota_error();
}
}
#endif // USE_ARDUINO
#ifdef USE_ESP_IDF
// ESP-IDF implementation
if (index == 0 && !this->ota_backend_) {
// Initialize OTA on first call
this->ota_init_(filename.c_str());
this->ota_success_ = false;
auto *backend = new IDFWebServerOTABackend();
if (!backend->begin()) {
ESP_LOGE(TAG, "OTA begin failed");
delete backend;
return;
}
this->ota_backend_ = backend;
}
auto *backend = static_cast<IDFWebServerOTABackend *>(this->ota_backend_);
if (!backend) {
return;
}
// Process data
if (len > 0) {
if (!backend->write(data, len)) {
ESP_LOGE(TAG, "OTA write failed");
backend->abort();
delete backend;
this->ota_backend_ = nullptr;
return;
}
this->ota_read_length_ += len;
this->report_ota_progress_(request);
}
// Finalize
if (final) {
this->ota_success_ = backend->end();
if (this->ota_success_) {
this->schedule_ota_reboot_();
} else {
ESP_LOGE(TAG, "OTA end failed");
}
delete backend;
this->ota_backend_ = nullptr;
}
#endif // USE_ESP_IDF
}
void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) {
AsyncWebServerResponse *response;
#ifdef USE_ARDUINO
if (!Update.hasError()) {
response = request->beginResponse(200, "text/plain", "Update Successful!");
} else {
StreamString ss;
ss.print("Update Failed: ");
Update.printError(ss);
response = request->beginResponse(200, "text/plain", ss);
}
#endif // USE_ARDUINO
#ifdef USE_ESP_IDF
// Send response based on the OTA result
response = request->beginResponse(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!");
#endif // USE_ESP_IDF
response->addHeader("Connection", "close");
request->send(response);
}
void WebServerBase::add_ota_handler() {
this->add_handler(new OTARequestHandler(this)); // NOLINT
}
#endif
float WebServerBase::get_setup_priority() const {
// Before WiFi (captive portal)
return setup_priority::WIFI + 2.0f;

View File

@ -17,6 +17,9 @@
namespace esphome {
namespace web_server_base {
class WebServerBase;
extern WebServerBase *global_web_server_base; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
namespace internal {
class MiddlewareHandler : public AsyncWebHandler {
@ -110,18 +113,10 @@ class WebServerBase : public Component {
void add_handler(AsyncWebHandler *handler);
#ifdef USE_WEBSERVER_OTA
void add_ota_handler();
#endif
void set_port(uint16_t port) { port_ = port; }
uint16_t get_port() const { return port_; }
protected:
#ifdef USE_WEBSERVER_OTA
friend class OTARequestHandler;
#endif
int initialized_{0};
uint16_t port_{80};
std::shared_ptr<AsyncWebServer> server_{nullptr};
@ -129,37 +124,6 @@ class WebServerBase : public Component {
internal::Credentials credentials_;
};
#ifdef USE_WEBSERVER_OTA
class OTARequestHandler : public AsyncWebHandler {
public:
OTARequestHandler(WebServerBase *parent) : parent_(parent) {}
void handleRequest(AsyncWebServerRequest *request) override;
void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
bool final) override;
bool canHandle(AsyncWebServerRequest *request) const override {
return request->url() == "/update" && request->method() == HTTP_POST;
}
// NOLINTNEXTLINE(readability-identifier-naming)
bool isRequestHandlerTrivial() const override { return false; }
protected:
void report_ota_progress_(AsyncWebServerRequest *request);
void schedule_ota_reboot_();
void ota_init_(const char *filename);
uint32_t last_ota_progress_{0};
uint32_t ota_read_length_{0};
WebServerBase *parent_;
private:
#ifdef USE_ESP_IDF
void *ota_backend_{nullptr};
bool ota_success_{false};
#endif
};
#endif // USE_WEBSERVER_OTA
} // namespace web_server_base
} // namespace esphome
#endif

View File

@ -1,7 +1,5 @@
from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
from esphome.components.esp32 import add_idf_sdkconfig_option
import esphome.config_validation as cv
from esphome.const import CONF_OTA, CONF_WEB_SERVER
from esphome.core import CORE
CODEOWNERS = ["@dentra"]
@ -14,7 +12,3 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Increase the maximum supported size of headers section in HTTP request packet to be processed by the server
add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024)
# Check if web_server component has OTA enabled
if CORE.config.get(CONF_WEB_SERVER, {}).get(CONF_OTA, True):
# Add multipart parser component for ESP-IDF OTA support
add_idf_component(name="zorxx/multipart-parser", ref="1.0.1")

View File

@ -67,6 +67,42 @@ ConfigPath = list[str | int]
path_context = contextvars.ContextVar("Config path")
def _process_platform_config(
result: Config,
component_name: str,
platform_name: str,
platform_config: ConfigType,
path: ConfigPath,
) -> None:
"""Process a platform configuration and add necessary validation steps.
This is shared between LoadValidationStep and AutoLoadValidationStep to avoid duplication.
"""
# Get the platform manifest
platform = get_platform(component_name, platform_name)
if platform is None:
result.add_str_error(
f"Platform not found: '{component_name}.{platform_name}'", path
)
return
# Add platform to loaded integrations
CORE.loaded_integrations.add(platform_name)
CORE.loaded_platforms.add(f"{component_name}/{platform_name}")
# Process platform's AUTO_LOAD
for load in platform.auto_load:
if load not in result:
result.add_validation_step(AutoLoadValidationStep(load))
# Add validation steps for the platform
p_domain = f"{component_name}.{platform_name}"
result.add_output_path(path, p_domain)
result.add_validation_step(
MetadataValidationStep(path, p_domain, platform_config, platform)
)
def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool:
if len(path) < len(other):
return False
@ -379,26 +415,11 @@ class LoadValidationStep(ConfigValidationStep):
path,
)
continue
# Remove temp output path and construct new one
# Remove temp output path
result.remove_output_path(path, p_domain)
p_domain = f"{self.domain}.{p_name}"
result.add_output_path(path, p_domain)
# Try Load platform
platform = get_platform(self.domain, p_name)
if platform is None:
result.add_str_error(f"Platform not found: '{p_domain}'", path)
continue
CORE.loaded_integrations.add(p_name)
CORE.loaded_platforms.add(f"{self.domain}/{p_name}")
# Process AUTO_LOAD
for load in platform.auto_load:
if load not in result:
result.add_validation_step(AutoLoadValidationStep(load))
result.add_validation_step(
MetadataValidationStep(path, p_domain, p_config, platform)
)
# Process the platform configuration
_process_platform_config(result, self.domain, p_name, p_config, path)
class AutoLoadValidationStep(ConfigValidationStep):
@ -413,10 +434,56 @@ class AutoLoadValidationStep(ConfigValidationStep):
self.domain = domain
def run(self, result: Config) -> None:
if self.domain in result:
# already loaded
# Regular component auto-load (no platform)
if "." not in self.domain:
if self.domain in result:
# already loaded
return
result.add_validation_step(LoadValidationStep(self.domain, core.AutoLoad()))
return
result.add_validation_step(LoadValidationStep(self.domain, core.AutoLoad()))
# Platform-specific auto-load (e.g., "ota.web_server")
component_name, _, platform_name = self.domain.partition(".")
# Check if component exists
if component_name not in result:
# Component doesn't exist, load it first
result.add_validation_step(LoadValidationStep(component_name, []))
# Re-run this step after the component is loaded
result.add_validation_step(AutoLoadValidationStep(self.domain))
return
# Component exists, check if it's a platform component
component = get_component(component_name)
if component is None or not component.is_platform_component:
result.add_str_error(
f"Component {component_name} is not a platform component, "
f"cannot auto-load platform {platform_name}",
[component_name],
)
return
# Ensure the component config is a list
component_conf = result.get(component_name)
if not isinstance(component_conf, list):
component_conf = result[component_name] = []
# Check if platform already exists
if any(
isinstance(conf, dict) and conf.get(CONF_PLATFORM) == platform_name
for conf in component_conf
):
return
# Add and process the platform configuration
platform_conf = core.AutoLoad()
platform_conf[CONF_PLATFORM] = platform_name
component_conf.append(platform_conf)
path = [component_name, len(component_conf) - 1]
_process_platform_config(
result, component_name, platform_name, platform_conf, path
)
class MetadataValidationStep(ConfigValidationStep):

View File

@ -0,0 +1,102 @@
"""Tests for the web_server OTA platform."""
from collections.abc import Callable
def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None:
"""Test that web_server OTA platform generates correct code."""
main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota.yaml")
# Check that the web server OTA component is included
assert "WebServerOTAComponent" in main_cpp
assert "web_server::WebServerOTAComponent" in main_cpp
# Check that global web server base is referenced
assert "global_web_server_base" in main_cpp
# Check component is registered
assert "App.register_component(web_server_webserverotacomponent_id)" in main_cpp
def test_web_server_ota_with_callbacks(generate_main: Callable[[str], str]) -> None:
"""Test web_server OTA with state callbacks."""
main_cpp = generate_main(
"tests/component_tests/ota/test_web_server_ota_callbacks.yaml"
)
# Check that web server OTA component is present
assert "WebServerOTAComponent" in main_cpp
# Check that callbacks are configured
# The actual callback code is in the component implementation, not main.cpp
# But we can check that logger.log statements are present from the callbacks
assert "logger.log" in main_cpp
assert "OTA started" in main_cpp
assert "OTA completed" in main_cpp
assert "OTA error" in main_cpp
def test_web_server_ota_idf_multipart(generate_main: Callable[[str], str]) -> None:
"""Test that ESP-IDF builds include multipart parser dependency."""
main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota_idf.yaml")
# Check that web server OTA component is present
assert "WebServerOTAComponent" in main_cpp
# For ESP-IDF builds, the framework type is esp-idf
# The multipart parser dependency is added by web_server_idf
assert "web_server::WebServerOTAComponent" in main_cpp
def test_web_server_ota_without_web_server_fails(
generate_main: Callable[[str], str],
) -> None:
"""Test that web_server OTA requires web_server component."""
# This should fail during validation since web_server_base is required
# but we can't test validation failures with generate_main
# Instead, verify that both components are needed in valid config
main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota.yaml")
# Both web server and OTA components should be present
assert "WebServer" in main_cpp
assert "WebServerOTAComponent" in main_cpp
def test_multiple_ota_platforms(generate_main: Callable[[str], str]) -> None:
"""Test multiple OTA platforms can coexist."""
main_cpp = generate_main("tests/component_tests/ota/test_web_server_ota_multi.yaml")
# Check all OTA platforms are included
assert "WebServerOTAComponent" in main_cpp
assert "ESPHomeOTAComponent" in main_cpp
assert "OtaHttpRequestComponent" in main_cpp
# Check components are from correct namespaces
assert "web_server::WebServerOTAComponent" in main_cpp
assert "esphome::ESPHomeOTAComponent" in main_cpp
assert "http_request::OtaHttpRequestComponent" in main_cpp
def test_web_server_ota_arduino_with_auth(generate_main: Callable[[str], str]) -> None:
"""Test web_server OTA with Arduino framework and authentication."""
main_cpp = generate_main(
"tests/component_tests/ota/test_web_server_ota_arduino.yaml"
)
# Check web server OTA component is present
assert "WebServerOTAComponent" in main_cpp
# Check authentication is set up for web server
assert "set_auth_username" in main_cpp
assert "set_auth_password" in main_cpp
def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None:
"""Test web_server OTA on ESP8266 platform."""
main_cpp = generate_main(
"tests/component_tests/ota/test_web_server_ota_esp8266.yaml"
)
# Check web server OTA component is present
assert "WebServerOTAComponent" in main_cpp
assert "web_server::WebServerOTAComponent" in main_cpp

View File

@ -0,0 +1,15 @@
esphome:
name: test_web_server_ota
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
web_server:
port: 80
ota:
- platform: web_server

View File

@ -0,0 +1,18 @@
esphome:
name: test_web_server_ota_arduino
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
web_server:
port: 80
auth:
username: admin
password: admin
ota:
- platform: web_server

View File

@ -0,0 +1,31 @@
esphome:
name: test_web_server_ota_callbacks
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
web_server:
port: 80
ota:
- platform: web_server
on_begin:
- logger.log: "OTA started"
on_progress:
- logger.log:
format: "OTA progress: %.1f%%"
args: ["x"]
on_end:
- logger.log: "OTA completed"
on_error:
- logger.log:
format: "OTA error: %d"
args: ["x"]
on_state_change:
- logger.log: "OTA state changed"

View File

@ -0,0 +1,15 @@
esphome:
name: test_web_server_ota_esp8266
esp8266:
board: nodemcuv2
wifi:
ssid: MySSID
password: password1
web_server:
port: 80
ota:
- platform: web_server

View File

@ -0,0 +1,17 @@
esphome:
name: test_web_server_ota_idf
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
port: 80
ota:
- platform: web_server

View File

@ -0,0 +1,21 @@
esphome:
name: test_web_server_ota_multi
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
web_server:
port: 80
http_request:
verify_ssl: false
ota:
- platform: esphome
password: "test_password"
- platform: web_server
- platform: http_request

View File

@ -0,0 +1,38 @@
"""Tests for web_server OTA migration validation."""
import pytest
from esphome import config_validation as cv
from esphome.types import ConfigType
def test_web_server_ota_true_fails_validation() -> None:
"""Test that web_server with ota: true fails validation with helpful message."""
from esphome.components.web_server import validate_ota_removed
# Config with ota: true should fail
config: ConfigType = {"ota": True}
with pytest.raises(cv.Invalid) as exc_info:
validate_ota_removed(config)
# Check error message contains migration instructions
error_msg = str(exc_info.value)
assert "has been removed from 'web_server'" in error_msg
assert "platform: web_server" in error_msg
assert "ota:" in error_msg
def test_web_server_ota_false_passes_validation() -> None:
"""Test that web_server with ota: false passes validation."""
from esphome.components.web_server import validate_ota_removed
# Config with ota: false should pass
config: ConfigType = {"ota": False}
result = validate_ota_removed(config)
assert result == config
# Config without ota should also pass
config: ConfigType = {}
result = validate_ota_removed(config)
assert result == config

View File

@ -1,3 +1,11 @@
esphome:
name: test-web-server-no-ota-idf
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
device_base: !include common.yaml
@ -6,4 +14,3 @@ packages:
web_server:
port: 8080
version: 2
ota: false

View File

@ -1,8 +1,6 @@
# Test configuration for ESP-IDF web server with OTA enabled
esphome:
name: test-web-server-ota-idf
# Force ESP-IDF framework
esp32:
board: esp32dev
framework:
@ -15,17 +13,17 @@ packages:
ota:
- platform: esphome
password: "test_ota_password"
- platform: web_server
# Web server with OTA enabled
# Web server configuration
web_server:
port: 8080
version: 2
ota: true
include_internal: true
# Enable debug logging for OTA
logger:
level: DEBUG
level: VERBOSE
logs:
web_server: VERBOSE
web_server_idf: VERBOSE

View File

@ -1,11 +1,18 @@
esphome:
name: test-ws-ota-disabled-idf
esp32:
board: esp32dev
framework:
type: esp-idf
packages:
device_base: !include common.yaml
# OTA is configured but web_server OTA is disabled
# OTA is configured but web_server OTA is NOT included
ota:
- platform: esphome
web_server:
port: 8080
version: 2
ota: false