diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 0447b968a3..a83195d42d 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,5 +1,9 @@ #include "ota_backend.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + namespace esphome::ota { bool version_is_older(const char *candidate, const char *reference) { @@ -30,6 +34,60 @@ bool version_is_older(const char *candidate, const char *reference) { } } +// Non-blocking; may be released by another task than the one that took it. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// uint32_t: narrower atomics compile to a masked CAS on Xtensa. +static std::atomic update_locked{0}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static bool update_lock_test_and_set() { return update_locked.exchange(1) == 0; } +static void update_lock_clear() { update_locked.store(0); } +#else +static bool update_locked = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// BK72xx has no atomic exchange. +static Mutex &update_lock_mutex() { + static Mutex mutex; + return mutex; +} +#endif + +static bool update_lock_test_and_set() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#else + // lwIP callbacks may run from an interrupt (RP2040). + InterruptLock guard; +#endif + if (update_locked) + return false; + update_locked = true; + return true; +} + +static void update_lock_clear() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#endif + update_locked = false; +} +#endif + +bool UpdateLock::try_acquire() { + if (!this->held_) + this->held_ = update_lock_test_and_set(); + return this->held_; +} + +void UpdateLock::release() { + if (!this->held_) + return; + update_lock_clear(); + this->held_ = false; +} + +void UpdateLock::force_release() { update_lock_clear(); } + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index a505d58efe..840d1ac11c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -51,6 +51,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, + OTA_RESPONSE_ERROR_UPDATE_BUSY = 0x95, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -95,6 +96,29 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +/// Serializes flash updates. Taken in begin(), released when the backend is destroyed, or kept until +/// reboot after a successful end(). Also for components writing the update partition outside OTA. +class UpdateLock { + public: + UpdateLock() = default; + ~UpdateLock() { this->release(); } + UpdateLock(const UpdateLock &) = delete; + UpdateLock &operator=(const UpdateLock &) = delete; + + /// Returns false if held elsewhere. + bool try_acquire(); + void release(); + bool is_held() const { return this->held_; } + /// Keep the lock set until reboot; is_held() becomes false. + void hold_until_reboot() { this->held_ = false; } + + protected: + /// Tests only. + static void force_release(); + + bool held_{false}; +}; + // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 02a5c5750f..33af42b6a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -14,6 +14,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -57,6 +59,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::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_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -66,7 +69,11 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; + Update.abort(); +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c322ed21f2..fc277bb5e4 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -16,6 +16,7 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 74f44c0b3b..d4d94be236 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -16,6 +16,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -63,6 +65,7 @@ OTAResponseTypes ArduinoRP2OTABackend::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_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -73,6 +76,8 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { } void ArduinoRP2OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; Update.end(); rp2::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index 15142869ac..93b9dcb75c 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -21,6 +21,7 @@ class ArduinoRP2OTABackend final { static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 5be55a5200..3d6148e64f 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -51,6 +51,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -303,10 +305,13 @@ OTAResponseTypes ESP8266OTABackend::end() { this->buffer_.reset(); esp8266::preferences_prevent_write(false); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void ESP8266OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 1f1ec37eee..bd4c9a1bce 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -23,6 +23,7 @@ class ESP8266OTABackend final { static constexpr bool supports_compression() { return true; } protected: + UpdateLock lock_; /// Erase flash sector if current address is at sector boundary bool erase_sector_if_needed_(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2316aad5cf..fd6fd7f6f1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -20,6 +20,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { @@ -196,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->update_partition_table(); + return this->hold_lock_if_ok_(this->update_partition_table()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; @@ -209,7 +211,7 @@ OTAResponseTypes IDFOTABackend::end() { } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { - return this->finalize_bootloader_update_(err); + return this->hold_lock_if_ok_(this->finalize_bootloader_update_(err)); } #endif if (err == ESP_OK) { @@ -242,7 +244,7 @@ OTAResponseTypes IDFOTABackend::end() { #endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { - return OTA_RESPONSE_OK; + return this->hold_lock_if_ok_(OTA_RESPONSE_OK); } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { @@ -260,6 +262,8 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 4f4093a594..d166bb361f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -36,6 +36,12 @@ class IDFOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; + OTAResponseTypes hold_lock_if_ok_(OTAResponseTypes result) { + if (result == OTA_RESPONSE_OK) + this->lock_.hold_until_reboot(); + return result; + } #ifdef USE_OTA_PARTITIONS // copy_dest_part non-null means the running app must be copied INTO this slot of the current // table before the new partition table is committed. The destination is in the current table diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 456fbb5863..923b0afe18 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -200,6 +200,8 @@ bool validate_executable_(const char *staging_path, const std::string &exe_path) std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. @@ -318,10 +320,13 @@ OTAResponseTypes HostOTABackend::end() { host::arm_reexec(this->final_path_); this->staging_path_.clear(); ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void HostOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; if (this->fd_ >= 0) { ::close(this->fd_); this->fd_ = -1; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index e53868f102..6c86fe10ba 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -22,6 +22,7 @@ class HostOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; md5::MD5Digest md5_{}; std::string staging_path_; std::string final_path_; diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 3cf1d9802e..8a668ae0fc 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -9,12 +9,6 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) -#include -#endif -#endif // USE_ARDUINO - #if USE_ESP32 using PlatformString = std::string; #elif USE_ARDUINO @@ -64,6 +58,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + /// Abort and drop the backend, releasing the lock. + void abort_session_(); + /// Reply "busy" without touching the running upload. + void refuse_(AsyncWebServerRequest *request); uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -72,6 +70,9 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + /// Request that owns ota_backend_. + const AsyncWebServerRequest *ota_request_{nullptr}; + const AsyncWebServerRequest *refused_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -118,38 +119,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // fires a separate start-marker call with data==nullptr/len==0 before the // first real chunk; gate on len>0 so we only trigger once per upload.) if (index == 0 && len > 0) { - // If a previous upload was interrupted (e.g. client closed the tab, TCP - // reset) the backend from that session may still be open. Tear it down - // so flash state doesn't get concatenated with the new image (which can - // produce a technically-valid-sized but corrupted firmware that bricks - // the device once it reboots). + // A stalled upload times out and onDisconnect() frees it, so refusing can't block for long. if (this->ota_backend_) { - ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); - this->ota_backend_->abort(); -#ifdef USE_OTA_STATE_LISTENER - // Notify listeners that the previous session was aborted before the new one starts. - this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); -#endif - this->ota_backend_.reset(); + ESP_LOGW(TAG, "OTA upload refused: another upload is in progress"); + this->refuse_(request); + return; } - // Initialize OTA on first call - this->ota_init_(filename.c_str()); - -#ifdef USE_OTA_STATE_LISTENER - // Notify OTA started - use notify_state_deferred_ since we're in web server task - this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); -#endif - - // Platform-specific pre-initialization -#ifdef USE_ARDUINO -#if 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"); @@ -164,6 +140,22 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // 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_ERROR_UPDATE_BUSY) { + // Nothing started: no state notifications. + ESP_LOGW(TAG, "OTA upload refused: another update is in progress"); + this->ota_backend_.reset(); + this->refuse_(request); + return; + } + + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); +#endif + if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); @@ -172,9 +164,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif return; } + + // An interrupted upload gets no final chunk. + this->ota_request_ = request; + request->onDisconnect([this, request]() { + if (this->ota_request_ == request && this->ota_backend_) { + ESP_LOGW(TAG, "OTA upload interrupted; aborting"); + this->abort_session_(); + } + }); } - if (!this->ota_backend_) { + if (!this->ota_backend_ || request != this->ota_request_) { return; } @@ -185,6 +186,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -212,28 +214,54 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); + // Reset the platform updater before the lock is released. + this->ota_backend_->abort(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } +void OTARequestHandler::abort_session_() { + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + this->ota_request_ = nullptr; +} + +void OTARequestHandler::refuse_(AsyncWebServerRequest *request) { + this->refused_request_ = request; + request->onDisconnect([this, request]() { + if (this->refused_request_ == request) + this->refused_request_ = nullptr; + }); +} + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result + const bool refused = request == this->refused_request_; + if (refused) + this->refused_request_ = nullptr; #ifdef USE_ESP8266 static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char UPDATE_BUSY[] PROGMEM = "Update Failed! Another update is in progress, retry later."; static const char TEXT_PLAIN[] PROGMEM = "text/plain"; static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; - const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + const char *msg = refused ? UPDATE_BUSY : (this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED); response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else - const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + const char *msg = refused ? "Update Failed! Another update is in progress, retry later." + : this->ota_success_ ? "Update Successful!" + : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2153e232a0..d9a7d65e8c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -311,6 +311,10 @@ esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_t } AsyncWebServerRequest::~AsyncWebServerRequest() { +#ifdef USE_WEBSERVER_OTA + if (this->on_disconnect_) + this->on_disconnect_(); +#endif delete this->rsp_; for (auto *param : this->params_) { delete param; // NOLINT(cppcoreguidelines-owning-memory) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f9094b05c5..4d4490d907 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -183,6 +183,12 @@ class AsyncWebServerRequest { std::string arg(const char *name); std::string arg(const std::string &name) { return this->arg(name.c_str()); } +#ifdef USE_WEBSERVER_OTA + /// Called when the request ends, including early returns. + // NOLINTNEXTLINE(readability-identifier-naming) + void onDisconnect(std::function fn) { this->on_disconnect_ = std::move(fn); } +#endif + operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; // NOLINTNEXTLINE(readability-identifier-naming) @@ -198,6 +204,9 @@ class AsyncWebServerRequest { optional find_query_value_(const char *name) const; std::vector params_; std::string post_query_; +#ifdef USE_WEBSERVER_OTA + std::function on_disconnect_; +#endif AsyncWebServerRequest(httpd_req_t *req) : req_(req) {} AsyncWebServerRequest(httpd_req_t *req, std::string post_query) : req_(req), post_query_(std::move(post_query)) {} void init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type); diff --git a/esphome/espota2.py b/esphome/espota2.py index f775afe7f6..d8a2ff9000 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -54,6 +54,7 @@ RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94 +RESPONSE_ERROR_UPDATE_BUSY = 0x95 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -203,6 +204,10 @@ _ERROR_MESSAGES: dict[int, str] = { "of the YAML this upload uses, or update your esphome installation if it " "predates OTA encryption." ), + RESPONSE_ERROR_UPDATE_BUSY: ( + "The device is busy with another update that writes to its flash. " + "Wait for it to finish, then retry." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/components/ota/test_update_lock.cpp b/tests/components/ota/test_update_lock.cpp new file mode 100644 index 0000000000..28314cb1c2 --- /dev/null +++ b/tests/components/ota/test_update_lock.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/ota/ota_backend_factory.h" + +namespace esphome::ota::testing { + +TEST(UpdateLock, SecondHolderIsRefused) { + UpdateLock first; + UpdateLock second; + EXPECT_TRUE(first.try_acquire()); + EXPECT_FALSE(second.try_acquire()); + first.release(); + EXPECT_TRUE(second.try_acquire()); +} + +TEST(UpdateLock, AcquireIsIdempotentForTheHolder) { + UpdateLock lock; + EXPECT_TRUE(lock.try_acquire()); + EXPECT_TRUE(lock.try_acquire()); + lock.release(); + UpdateLock other; + EXPECT_TRUE(other.try_acquire()); +} + +TEST(UpdateLock, ReleaseByNonHolderKeepsLock) { + UpdateLock holder; + UpdateLock other; + EXPECT_TRUE(holder.try_acquire()); + other.release(); + EXPECT_FALSE(other.try_acquire()); +} + +TEST(UpdateLock, DestructorReleases) { + { + UpdateLock scoped; + EXPECT_TRUE(scoped.try_acquire()); + } + UpdateLock next; + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, IsHeldTracksOwnership) { + UpdateLock holder; + UpdateLock other; + EXPECT_FALSE(holder.is_held()); + EXPECT_TRUE(holder.try_acquire()); + EXPECT_TRUE(holder.is_held()); + EXPECT_FALSE(other.try_acquire()); + EXPECT_FALSE(other.is_held()); +} + +class TestUpdateLock : public UpdateLock { + public: + using UpdateLock::force_release; +}; + +TEST(UpdateLock, HoldUntilRebootOutlivesHolder) { + { + UpdateLock committed; + EXPECT_TRUE(committed.try_acquire()); + committed.hold_until_reboot(); + EXPECT_FALSE(committed.is_held()); + } + UpdateLock next; + EXPECT_FALSE(next.try_acquire()); + TestUpdateLock::force_release(); + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, SecondBackendBeginIsBusy) { + auto first = make_ota_backend(); + auto second = make_ota_backend(); + ASSERT_EQ(first->begin(1024), OTA_RESPONSE_OK); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_ERROR_UPDATE_BUSY); + // Native OTA calls abort() after a refused begin(). + second->abort(); + uint8_t data[16] = {}; + EXPECT_EQ(first->write(data, sizeof(data)), OTA_RESPONSE_OK); + first->abort(); + first.reset(); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_OK); + second->abort(); +} + +} // namespace esphome::ota::testing diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 6fa6c616b6..f1e04da8c8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -263,6 +263,7 @@ def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) - espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, "An error occurred while updating the bootloader", ), + (espota2.RESPONSE_ERROR_UPDATE_BUSY, "The device is busy with another update"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], )