From 81766f5bbe54ec5816aced2887c4f1f61706c988 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:55:34 +0200 Subject: [PATCH 01/70] Add partition table update functionality to ota component --- esphome/__main__.py | 19 +- esphome/components/esphome/ota/__init__.py | 11 +- .../components/esphome/ota/ota_esphome.cpp | 58 +++- esphome/components/esphome/ota/ota_esphome.h | 3 + esphome/components/ota/ota_backend.h | 7 + .../components/ota/ota_backend_esp_idf.cpp | 294 ++++++++++++++---- esphome/components/ota/ota_backend_esp_idf.h | 18 +- esphome/core/__init__.py | 4 + esphome/espota2.py | 64 +++- 9 files changed, 396 insertions(+), 82 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 87abd7f796..87682860c8 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1009,15 +1009,19 @@ def upload_program( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) - if getattr(args, "file", None) is not None: - binary = Path(args.file) - else: - binary = CORE.firmware_bin # Resolve MQTT magic strings to actual IP addresses network_devices = _resolve_network_devices(devices, config, args) - return espota2.run_ota(network_devices, remote_port, password, binary) + binary = CORE.firmware_bin + ota_type = espota2.OTA_TYPE_UPDATE_APP + if getattr(args, "partition_table", False): + binary = CORE.partition_table_bin + ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE + if getattr(args, "file", None) is not None: + binary = Path(args.file) + + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: @@ -1646,6 +1650,11 @@ def parse_args(argv): "--file", help="Manually specify the binary file to upload.", ) + parser_upload.add_argument( + "--partition-table", + help="Upload as partition table", + action="store_true", + ) parser_logs = subparsers.add_parser( "logs", diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 337064dd27..7684df880f 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,11 +16,13 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" + _LOGGER = logging.getLogger(__name__) @@ -75,6 +77,10 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) + if config[CONF_ALLOW_PARTITION_ACCESS] and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" + ) else: new_ota_conf.append(ota_conf) @@ -117,6 +123,7 @@ CONFIG_SCHEMA = cv.All( ln882x=8820, rtl87xx=8892, ): cv.port, + cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" @@ -147,6 +154,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + if config[CONF_ALLOW_PARTITION_ACCESS]: + cg.add_define("USE_OTA_PARTITIONS") await cg.register_component(var, config) await ota_to_code(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af9b8ee19a..5edabdf1f3 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -90,8 +90,11 @@ void ESPHomeOTAComponent::loop() { } } -static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static const uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static const uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static const uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static const uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static const uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -177,18 +180,40 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; +#ifdef USE_OTA_PARTITIONS + this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; + if (this->extended_proto_) { + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; + this->handshake_buf_[1] = 0; + if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) { + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; + } + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; + + } else { +#endif + this->handshake_buf_[0] = + ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; +#ifdef USE_OTA_PARTITIONS + } +#endif [[fallthrough]]; } case OTAState::FEATURE_ACK: { // Acknowledge header - 1 byte +#ifdef USE_OTA_PARTITIONS + if (!this->try_write_(this->extended_proto_ ? 2 : 1, LOG_STR("ack feature"))) { + return; + } +#else if (!this->try_write_(1, LOG_STR("ack feature"))) { return; } +#endif + #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -271,6 +296,9 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; +#ifdef USE_OTA_PARTITIONS + ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; +#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -286,6 +314,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); +#ifdef USE_OTA_PARTITIONS + if (this->extended_proto_) { + // Read ota type, 1 byte + if (!this->readall_(buf, 1)) { + this->log_read_error_(LOG_STR("OTA type")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + ota_type = static_cast(buf[0]); + } + ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); +#endif + // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); @@ -306,7 +346,11 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash +#ifdef USE_OTA_PARTITIONS + error_code = this->backend_->begin(ota_size, ota_type); +#else error_code = this->backend_->begin(ota_size); +#endif if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; @@ -577,7 +621,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } bool ESPHomeOTAComponent::select_auth_type_() { - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0; // Require SHA256 if (!client_supports_sha256) { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f3a5952398..f3513e9603 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -83,6 +83,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_PARTITIONS + bool extended_proto_{false}; +#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index bd9c481901..18e2fa3802 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -23,6 +23,7 @@ enum OTAResponseTypes { OTA_RESPONSE_UPDATE_END_OK = 0x45, OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46, OTA_RESPONSE_CHUNK_OK = 0x47, + OTA_RESPONSE_FEATURE_FLAGS = 0x48, OTA_RESPONSE_ERROR_MAGIC = 0x80, OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81, @@ -38,6 +39,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, + OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -49,6 +51,11 @@ enum OTAState { OTA_ERROR, }; +enum OTAType { + OTA_TYPE_UPDATE_APP = 0x00, + OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01, +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 598fce1562..c4226e7423 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -9,60 +9,80 @@ #include #include +#ifdef USE_OTA_PARTITIONS +#include +#endif + namespace esphome::ota { static const char *const TAG = "ota.idf"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes IDFOTABackend::begin(size_t image_size) { +OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + this->ota_type_ = ota_type; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { #ifdef USE_OTA_ROLLBACK - // If we're starting an OTA, the current boot is good enough - mark it valid - // to prevent rollback and allow the OTA to proceed even if the safe mode - // timer hasn't expired yet. - esp_ota_mark_app_valid_cancel_rollback(); + // If we're starting an OTA, the current boot is good enough - mark it valid + // to prevent rollback and allow the OTA to proceed even if the safe mode + // timer hasn't expired yet. + esp_ota_mark_app_valid_cancel_rollback(); #endif - this->partition_ = esp_ota_get_next_update_partition(nullptr); - if (this->partition_ == nullptr) { - return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; - } + this->partition_ = esp_ota_get_next_update_partition(nullptr); + if (this->partition_ == nullptr) { + return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; + } #if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - esp_task_wdt_config_t wdtc; - wdtc.idle_core_mask = 0; + // The following function takes longer than the 5 seconds timeout of WDT + 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); + wdtc.idle_core_mask |= (1 << 0); #endif #if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 - wdtc.idle_core_mask |= (1 << 1); + wdtc.idle_core_mask |= (1 << 1); #endif - wdtc.timeout_ms = 15000; - wdtc.trigger_panic = false; - esp_task_wdt_reconfigure(&wdtc); + wdtc.timeout_ms = 15000; + wdtc.trigger_panic = false; + esp_task_wdt_reconfigure(&wdtc); #endif - esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); + esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); #if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); + // Set the WDT back to the configured timeout + wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; + esp_task_wdt_reconfigure(&wdtc); #endif - if (err != ESP_OK) { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - if (err == ESP_ERR_INVALID_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + if (err == ESP_ERR_INVALID_SIZE) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; } - return OTA_RESPONSE_ERROR_UNKNOWN; + this->md5_.init(); + return OTA_RESPONSE_OK; } - this->md5_.init(); - return OTA_RESPONSE_OK; +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } + memset(this->buf_, 0xFF, sizeof this->buf_); + this->buf_written_ = 0; + this->image_size_ = image_size; + this->md5_.init(); + return OTA_RESPONSE_OK; + } +#endif + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } void IDFOTABackend::set_update_md5(const char *expected_md5) { @@ -71,17 +91,31 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { } OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { - esp_err_t err = esp_ota_write(this->update_handle_, data, len); - this->md5_.add(data, len); - if (err != ESP_OK) { - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { - return OTA_RESPONSE_ERROR_MAGIC; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { + esp_err_t err = esp_ota_write(this->update_handle_, data, len); + this->md5_.add(data, len); + if (err != ESP_OK) { + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + return OTA_RESPONSE_ERROR_MAGIC; + } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; } - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_OK; } - return OTA_RESPONSE_OK; +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + if (len > OTA_BUFFER_SIZE - this->buf_written_) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } + memcpy(this->buf_ + this->buf_written_, data, len); + this->buf_written_ += len; + this->md5_.add(data, len); + return OTA_RESPONSE_OK; + } +#endif + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } OTAResponseTypes IDFOTABackend::end() { @@ -92,32 +126,176 @@ OTAResponseTypes IDFOTABackend::end() { return OTA_RESPONSE_ERROR_MD5_MISMATCH; } } - esp_err_t err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; - if (err == ESP_OK) { - err = esp_ota_set_boot_partition(this->partition_); + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { + esp_err_t err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; if (err == ESP_OK) { - return OTA_RESPONSE_OK; + err = esp_ota_set_boot_partition(this->partition_); + if (err == ESP_OK) { + return OTA_RESPONSE_OK; + } } + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + #ifdef USE_OTA_SIGNED_VERIFICATION + ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; + #else + return OTA_RESPONSE_ERROR_UPDATE_END; + #endif + } + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; + } +#ifdef USE_OTA_PARTITIONS + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + return this->update_partition_table(); } - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { -#ifdef USE_OTA_SIGNED_VERIFICATION - ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); - return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; -#else - return OTA_RESPONSE_ERROR_UPDATE_END; #endif - } - if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } void IDFOTABackend::abort() { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; + if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + } +#ifdef USE_OTA_PARTITIONS + if (this->partition_table_part_ != nullptr) { + esp_partition_deregister_external(this->partition_table_part_); + this->partition_table_part_ = nullptr; + } +#endif } +#ifdef USE_OTA_PARTITIONS +OTAResponseTypes IDFOTABackend::update_partition_table() { + esp_err_t err; + int num_partitions; + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "not enough data received (%d/%d bytes)", this->buf_written_, this->image_size_); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + ESP_LOGD(TAG, "partition table size %d", this->image_size_); + + // Get running app partition and used size + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + size_t running_app_size = running_app_part->size; + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata; + image_metadata.start_addr = running_app_part->address; + err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); + if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { + running_app_size = image_metadata.image_len; + } + + // Get partition table partition + err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + // Verify existing partition table + const esp_partition_info_t *existing_partition_table = NULL; + esp_partition_mmap_handle_t partition_table_map; + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, (const void**)&existing_partition_table, &partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); + esp_partition_munmap(partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // Verify new partition table + const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *)this->buf_; + // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data + err = esp_partition_table_verify(new_partition_table, true, &num_partitions); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // Check if the required app and otadata partitions exist in the new partition table + // Check which app slot to boot from in the new partition table + int app_partitions_found = 0; + int app_index = -1; + int app_index_with_copy = -1; + int otadata_index = -1; + bool otadata_no_overlap = false; + for (int i = 0; i < num_partitions; i++) { + const esp_partition_info_t *part = &new_partition_table[i]; + if (part->type == ESP_PARTITION_TYPE_APP) { + app_partitions_found++; + if (part->pos.size >= running_app_size) { + if (part->pos.offset == running_app_part->address) { + app_index = i; + } else if (part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size) { + // No overlap with running app + app_index_with_copy = i; + } + } + } else if (part->type == ESP_PARTITION_TYPE_DATA && part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + otadata_index = i; + otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size; + } + } + if (app_index == -1 && app_index_with_copy == -1) { + // Can't move running app to new partition layout + ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + if (app_partitions_found < 2 || otadata_index == -1) { + // OTA would be impossible with new partition table + ESP_LOGE(TAG, "New partition table is missing the required partitions for OTA"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + if (!otadata_no_overlap) { + // Can't write to new otadata partition because it overlaps with the running app + ESP_LOGE(TAG, "New otadata partition overlaps with running app"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + ESP_LOGD(TAG, "Checks passed, starting partition table update", err); + + // TODO: Copy the running app partition to new position if needed + if (app_index == -1) { + ESP_LOGE(TAG, "Moving the app partition is required but not implemented"); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // Update the partition table + err = esp_ota_begin(this->partition_table_part_, this->image_size_, &this->update_handle_); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + err = esp_ota_write(this->update_handle_, this->buf_, this->image_size_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + + // TODO: Reload partition table and rewrite otadata + + return OTA_RESPONSE_OK; +} +#endif + } // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index d007bcd128..0ee9532b48 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -9,21 +9,37 @@ namespace esphome::ota { +#ifdef USE_OTA_PARTITIONS +static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 +#endif + class IDFOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); bool supports_compression() { return false; } + protected: +#ifdef USE_OTA_PARTITIONS + OTAResponseTypes update_partition_table(); +#endif + private: esp_ota_handle_t update_handle_{0}; const esp_partition_t *partition_; md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; +#ifdef USE_OTA_PARTITIONS + uint8_t buf_[OTA_BUFFER_SIZE]; + size_t buf_written_{0}; + size_t image_size_{0}; + const esp_partition_t *partition_table_part_{nullptr}; +#endif }; std::unique_ptr make_ota_backend(); diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..4d5b8c5362 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -775,6 +775,10 @@ class EsphomeCore: return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.bin") + @property + def partition_table_bin(self): + return self.relative_pioenvs_path(self.name, "partitions.bin") + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/espota2.py b/esphome/espota2.py index 39f51e02e9..9767c4da04 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -15,6 +15,9 @@ from typing import Any from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +OTA_TYPE_UPDATE_APP = 0x00 +OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 + RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 RESPONSE_REQUEST_SHA256_AUTH = 0x02 @@ -27,6 +30,7 @@ RESPONSE_RECEIVE_OK = 0x44 RESPONSE_UPDATE_END_OK = 0x45 RESPONSE_SUPPORTS_COMPRESSION = 0x46 RESPONSE_CHUNK_OK = 0x47 +RESPONSE_FEATURE_FLAGS = 0x48 RESPONSE_ERROR_MAGIC = 0x80 RESPONSE_ERROR_UPDATE_PREPARE = 0x81 @@ -49,9 +53,11 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] -FEATURE_SUPPORTS_COMPRESSION = 0x01 -FEATURE_SUPPORTS_SHA256_AUTH = 0x02 - +CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01 +CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 +CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 +SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 +SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 @@ -232,7 +238,11 @@ def send_check( def perform_ota( - sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int, ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -251,7 +261,11 @@ def perform_ota( ) # Features - send both compression and SHA256 auth support - features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + features_to_send = ( + CLIENT_FEATURE_SUPPORTS_COMPRESSION + | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ) send_check(sock, features_to_send, "features") features = receive_exactly( sock, @@ -260,7 +274,26 @@ def perform_ota( None, # Accept any response )[0] - if features == RESPONSE_SUPPORTS_COMPRESSION: + extended_proto = False + if features == RESPONSE_FEATURE_FLAGS: + extended_proto = True + features = receive_exactly( + sock, + 1, + "feature flags", + None, # Accept any response + )[0] + elif features == RESPONSE_SUPPORTS_COMPRESSION: + features = SERVER_FEATURE_SUPPORTS_COMPRESSION + else: + features = 0 + + if ota_type != 0 and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS: + raise OTAError( + f"Device only supports app updates" + ) + + if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) else: @@ -315,6 +348,9 @@ def perform_ota( # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) + if extended_proto: + send_check(sock, ota_type, "ota type") + upload_size = len(upload_contents) upload_size_encoded = [ (upload_size >> 24) & 0xFF, @@ -375,7 +411,11 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -413,7 +453,7 @@ def run_ota_impl_( _LOGGER.info("Connected to %s", sa[0]) with open(filename, "rb") as file_handle: try: - perform_ota(sock, password, file_handle, filename) + perform_ota(sock, password, file_handle, filename, ota_type) except OTAError as err: _LOGGER.error(str(err)) return 1, None @@ -428,10 +468,14 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str | None, filename: Path + remote_host: str | list[str], + remote_port: int, + password: str | None, + filename: Path, + ota_type: int, ) -> tuple[int, str | None]: try: - return run_ota_impl_(remote_host, remote_port, password, filename) + return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) except OTAError as err: _LOGGER.error(err) return 1, None From 4b4d9a13f625814ee0b65b8f6ef720594e1a631b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:08:54 +0000 Subject: [PATCH 02/70] [pre-commit.ci lite] apply automatic fixes --- .../components/esphome/ota/ota_esphome.cpp | 3 +- .../components/ota/ota_backend_esp_idf.cpp | 28 +++++++++++-------- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/espota2.py | 4 +-- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 33fd0b7eed..bf03482451 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -209,7 +209,8 @@ void ESPHomeOTAComponent::handle_handshake_() { if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = 0; - if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) { + if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && + this->backend_->supports_compression()) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index c4226e7423..506d48988f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -72,7 +72,8 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { + if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || + image_size > OTA_BUFFER_SIZE) { return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } memset(this->buf_, 0xFF, sizeof this->buf_); @@ -136,12 +137,12 @@ OTAResponseTypes IDFOTABackend::end() { } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { - #ifdef USE_OTA_SIGNED_VERIFICATION +#ifdef USE_OTA_SIGNED_VERIFICATION ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; - #else +#else return OTA_RESPONSE_ERROR_UPDATE_END; - #endif +#endif } if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; @@ -183,8 +184,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); size_t running_app_size = running_app_part->size; const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, + .offset = running_app_part->address, + .size = running_app_part->size, }; esp_image_metadata_t image_metadata; image_metadata.start_addr = running_app_part->address; @@ -194,7 +195,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Get partition table partition - err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, + "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, + ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; @@ -202,7 +205,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Verify existing partition table const esp_partition_info_t *existing_partition_table = NULL; esp_partition_mmap_handle_t partition_table_map; - err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, (const void**)&existing_partition_table, &partition_table_map); + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, + (const void **) &existing_partition_table, &partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; @@ -215,7 +219,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify new partition table - const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *)this->buf_; + const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *) this->buf_; // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { @@ -237,14 +241,16 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (part->pos.size >= running_app_size) { if (part->pos.offset == running_app_part->address) { app_index = i; - } else if (part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size) { + } else if (part->pos.offset >= running_app_part->address + running_app_size || + running_app_part->address >= part->pos.offset + part->pos.size) { // No overlap with running app app_index_with_copy = i; } } } else if (part->type == ESP_PARTITION_TYPE_DATA && part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { otadata_index = i; - otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || running_app_part->address >= part->pos.offset + part->pos.size; + otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || + running_app_part->address >= part->pos.offset + part->pos.size; } } if (app_index == -1 && app_index_with_copy == -1) { diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 0ee9532b48..75aa66c29b 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,7 +10,7 @@ namespace esphome::ota { #ifdef USE_OTA_PARTITIONS -static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 +static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif class IDFOTABackend final { diff --git a/esphome/espota2.py b/esphome/espota2.py index 9767c4da04..7ee28a6b1c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -289,9 +289,7 @@ def perform_ota( features = 0 if ota_type != 0 and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS: - raise OTAError( - f"Device only supports app updates" - ) + raise OTAError("Device only supports app updates") if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: upload_contents = gzip.compress(file_contents, compresslevel=9) From aca11f17774b069fa3196eee08023d018bc2a55e Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:44:25 +0200 Subject: [PATCH 03/70] Fix --- esphome/components/esphome/ota/__init__.py | 2 +- esphome/espota2.py | 6 +++--- tests/unit_tests/test_main.py | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 92e9733f93..44b3997755 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -163,7 +163,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA_PASSWORD") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - if config[CONF_ALLOW_PARTITION_ACCESS]: + if config.get(CONF_ALLOW_PARTITION_ACCESS, False): cg.add_define("USE_OTA_PARTITIONS") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. diff --git a/esphome/espota2.py b/esphome/espota2.py index 7ee28a6b1c..f6ecb123b9 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -242,7 +242,7 @@ def perform_ota( password: str | None, file_handle: io.IOBase, filename: Path, - ota_type: int, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -413,7 +413,7 @@ def run_ota_impl_( remote_port: int, password: str | None, filename: Path, - ota_type: int, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -470,7 +470,7 @@ def run_ota( remote_port: int, password: str | None, filename: Path, - ota_type: int, + ota_type: int = OTA_TYPE_UPDATE_APP, ) -> tuple[int, str | None]: try: return run_ota_impl_(remote_host, remote_port, password, filename, ota_type) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e07b4accf2..09d0ab7512 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1587,7 +1587,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware + ["192.168.1.100"], 3232, "secret", expected_firmware, 0 ) @@ -1618,7 +1618,7 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin") + ["192.168.1.100"], 3232, None, Path("custom.bin"), 0 ) @@ -1676,7 +1676,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, 0 ) @@ -1724,7 +1724,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware + ["192.168.1.50"], 3232, None, expected_firmware, 0 ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -2698,7 +2698,7 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware + ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, 0 ) @@ -2741,7 +2741,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware, 0 ) @@ -2906,7 +2906,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware + ["192.168.1.100"], 3232, None, expected_firmware, 0 ) From 0af4521660238db1dddb691fd1d83a5df9c8f31c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:45:45 +0000 Subject: [PATCH 04/70] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 09d0ab7512..e1ec434ceb 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2741,7 +2741,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware, 0 + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], + 3232, + None, + expected_firmware, + 0, ) From 55ce6abf4b8d99e22afb583f5682b5900a8dbd13 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:56:32 +0200 Subject: [PATCH 05/70] Fix --- esphome/components/esphome/ota/__init__.py | 2 +- tests/unit_tests/test_espota2.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 44b3997755..0b5cbf96d3 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -77,7 +77,7 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if config[CONF_ALLOW_PARTITION_ACCESS] and not CORE.is_esp32: + if config.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: raise cv.Invalid( f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" ) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 20ba4b1f76..b08d4cecf2 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -274,8 +274,8 @@ def test_perform_ota_successful_md5_auth( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH ] ) ) @@ -644,8 +644,8 @@ def test_perform_ota_successful_sha256_auth( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH ] ) ) @@ -699,8 +699,8 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[1] == call( bytes( [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH ] ) ) From bbaab97f5769c92dc1ec37dd0b7a4ca21b9752b4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:06:42 +0200 Subject: [PATCH 06/70] Fix --- tests/unit_tests/test_espota2.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index b08d4cecf2..ab0ad7034c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -276,6 +276,7 @@ def test_perform_ota_successful_md5_auth( [ espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -646,6 +647,7 @@ def test_perform_ota_successful_sha256_auth( [ espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) @@ -701,6 +703,7 @@ def test_perform_ota_sha256_fallback_to_md5( [ espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL ] ) ) From aa1d7f853285365ca0030c7ea4bfd5d49b53e208 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:58:34 +0200 Subject: [PATCH 07/70] More conditional compilation, copy app partition, rewrite otadata --- .../components/ota/ota_backend_esp_idf.cpp | 328 +++++++++++------- esphome/components/ota/ota_backend_esp_idf.h | 6 +- 2 files changed, 210 insertions(+), 124 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 506d48988f..b23a2247e2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -17,60 +17,16 @@ namespace esphome::ota { static const char *const TAG = "ota.idf"; +#ifdef USE_OTA_PARTITIONS +static uint32_t running_app_offset = 0; +static size_t running_app_size = 0; +#endif + std::unique_ptr make_ota_backend() { return make_unique(); } +#ifdef USE_OTA_PARTITIONS OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { this->ota_type_ = ota_type; - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { -#ifdef USE_OTA_ROLLBACK - // If we're starting an OTA, the current boot is good enough - mark it valid - // to prevent rollback and allow the OTA to proceed even if the safe mode - // timer hasn't expired yet. - esp_ota_mark_app_valid_cancel_rollback(); -#endif - - this->partition_ = esp_ota_get_next_update_partition(nullptr); - if (this->partition_ == nullptr) { - return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; - } - -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - 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); -#endif - - esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); - -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - - if (err != ESP_OK) { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - if (err == ESP_ERR_INVALID_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; - } - this->md5_.init(); - return OTA_RESPONSE_OK; - } -#ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { @@ -82,8 +38,59 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) this->md5_.init(); return OTA_RESPONSE_OK; } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } +#else +OTAResponseTypes IDFOTABackend::begin(size_t image_size) { #endif - return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; +#ifdef USE_OTA_ROLLBACK + // If we're starting an OTA, the current boot is good enough - mark it valid + // to prevent rollback and allow the OTA to proceed even if the safe mode + // timer hasn't expired yet. + esp_ota_mark_app_valid_cancel_rollback(); +#endif + + this->partition_ = esp_ota_get_next_update_partition(nullptr); + if (this->partition_ == nullptr) { + return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; + } + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // The following function takes longer than the 5 seconds timeout of WDT + 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); +#endif + + esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // Set the WDT back to the configured timeout + wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; + esp_task_wdt_reconfigure(&wdtc); +#endif + + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + if (err == ESP_ERR_INVALID_SIZE) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; + } + this->md5_.init(); + return OTA_RESPONSE_OK; } void IDFOTABackend::set_update_md5(const char *expected_md5) { @@ -92,19 +99,6 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { } OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { - esp_err_t err = esp_ota_write(this->update_handle_, data, len); - this->md5_.add(data, len); - if (err != ESP_OK) { - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { - return OTA_RESPONSE_ERROR_MAGIC; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; - } - return OTA_RESPONSE_OK; - } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (len > OTA_BUFFER_SIZE - this->buf_written_) { @@ -115,8 +109,21 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { this->md5_.add(data, len); return OTA_RESPONSE_OK; } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } #endif - return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + esp_err_t err = esp_ota_write(this->update_handle_, data, len); + this->md5_.add(data, len); + if (err != ESP_OK) { + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + return OTA_RESPONSE_ERROR_MAGIC; + } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; + } + return OTA_RESPONSE_OK; } OTAResponseTypes IDFOTABackend::end() { @@ -127,47 +134,48 @@ OTAResponseTypes IDFOTABackend::end() { return OTA_RESPONSE_ERROR_MD5_MISMATCH; } } - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { - esp_err_t err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; - if (err == ESP_OK) { - err = esp_ota_set_boot_partition(this->partition_); - if (err == ESP_OK) { - return OTA_RESPONSE_OK; - } - } - if (err == ESP_ERR_OTA_VALIDATE_FAILED) { -#ifdef USE_OTA_SIGNED_VERIFICATION - ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); - return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; -#else - return OTA_RESPONSE_ERROR_UPDATE_END; -#endif - } - if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { - return OTA_RESPONSE_ERROR_WRITING_FLASH; - } - return OTA_RESPONSE_ERROR_UNKNOWN; - } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { return this->update_partition_table(); } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } #endif - return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + esp_err_t err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; + if (err == ESP_OK) { + err = esp_ota_set_boot_partition(this->partition_); + if (err == ESP_OK) { + return OTA_RESPONSE_OK; + } + } + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { +#ifdef USE_OTA_SIGNED_VERIFICATION + ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; +#else + return OTA_RESPONSE_ERROR_UPDATE_END; +#endif + } + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + return OTA_RESPONSE_ERROR_UNKNOWN; } void IDFOTABackend::abort() { - if (this->ota_type_ == ota::OTA_TYPE_UPDATE_APP) { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - } #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; } + if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { + return; + } #endif + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; } #ifdef USE_OTA_PARTITIONS @@ -178,20 +186,28 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "not enough data received (%d/%d bytes)", this->buf_written_, this->image_size_); return OTA_RESPONSE_ERROR_UNKNOWN; } - ESP_LOGD(TAG, "partition table size %d", this->image_size_); // Get running app partition and used size - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - size_t running_app_size = running_app_part->size; - const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, - }; - esp_image_metadata_t image_metadata; - image_metadata.start_addr = running_app_part->address; - err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); - if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { - running_app_size = image_metadata.image_len; + const esp_partition_t *running_app_part = nullptr; + if (running_app_size == 0) { + running_app_part = esp_ota_get_running_partition(); + // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on a previous run. + // Cache the running app offset and size. + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; + running_app_offset = running_app_part->address; + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata; + image_metadata.start_addr = running_app_part->address; + err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); + if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { + running_app_size = image_metadata.image_len; + } + // Align running_app_size to flash sectors + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; + ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); } // Get partition table partition @@ -234,47 +250,94 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { int app_index_with_copy = -1; int otadata_index = -1; bool otadata_no_overlap = false; + const esp_partition_t *app_copy_target_part{nullptr}; for (int i = 0; i < num_partitions; i++) { - const esp_partition_info_t *part = &new_partition_table[i]; - if (part->type == ESP_PARTITION_TYPE_APP) { + const esp_partition_info_t *new_part = &new_partition_table[i]; + if (new_part->type == ESP_PARTITION_TYPE_APP) { app_partitions_found++; - if (part->pos.size >= running_app_size) { - if (part->pos.offset == running_app_part->address) { + if (new_part->pos.size >= running_app_size) { + if (new_part->pos.offset == running_app_offset) { app_index = i; - } else if (part->pos.offset >= running_app_part->address + running_app_size || - running_app_part->address >= part->pos.offset + part->pos.size) { - // No overlap with running app - app_index_with_copy = i; + } else if (new_part->pos.offset >= running_app_offset + running_app_size || + running_app_offset >= new_part->pos.offset + new_part->pos.size) { + // New app partition has no overlap with running app + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == new_part->pos.offset && p->size >= running_app_size) { + // App partition exists in old and new partition table + app_index_with_copy = i; + app_copy_target_part = p; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); } } - } else if (part->type == ESP_PARTITION_TYPE_DATA && part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + } else if (new_part->type == ESP_PARTITION_TYPE_DATA && new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { otadata_index = i; - otadata_no_overlap = part->pos.offset >= running_app_part->address + running_app_size || - running_app_part->address >= part->pos.offset + part->pos.size; + otadata_no_overlap = new_part->pos.offset >= running_app_offset + running_app_size || + running_app_offset >= new_part->pos.offset + new_part->pos.size; } } if (app_index == -1 && app_index_with_copy == -1) { - // Can't move running app to new partition layout ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_UNKNOWN; } if (app_partitions_found < 2 || otadata_index == -1) { - // OTA would be impossible with new partition table - ESP_LOGE(TAG, "New partition table is missing the required partitions for OTA"); + ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); return OTA_RESPONSE_ERROR_UNKNOWN; } if (!otadata_no_overlap) { - // Can't write to new otadata partition because it overlaps with the running app ESP_LOGE(TAG, "New otadata partition overlaps with running app"); return OTA_RESPONSE_ERROR_UNKNOWN; } ESP_LOGD(TAG, "Checks passed, starting partition table update", err); - // TODO: Copy the running app partition to new position if needed + // Copy the running app partition to new position if needed if (app_index == -1) { - ESP_LOGE(TAG, "Moving the app partition is required but not implemented"); - return OTA_RESPONSE_ERROR_UNKNOWN; + if (running_app_part == nullptr) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + if (p->address == running_app_offset && p->size >= running_app_size) { + running_app_part = p; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + } + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // The following function takes longer than the 5 seconds timeout of WDT + 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); +#endif + + err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); + +#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 + // Set the WDT back to the configured timeout + wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; + esp_task_wdt_reconfigure(&wdtc); +#endif + + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } } // Update the partition table @@ -296,9 +359,28 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; } + esp_partition_unload_all(); - // TODO: Reload partition table and rewrite otadata + // Write otadata to set the new boot partition + const esp_partition_t *new_boot_partition = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + if (p->address == new_part->pos.offset) { + new_boot_partition = p; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + err = esp_ota_set_boot_partition(new_boot_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); + return OTA_RESPONSE_ERROR_UNKNOWN; + } + ESP_LOGD(TAG, "Partition table updated successfully", err); return OTA_RESPONSE_OK; } #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 75aa66c29b..9d38d0d8d8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -15,7 +15,11 @@ static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 class IDFOTABackend final { public: +#ifdef USE_OTA_PARTITIONS OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); +#else + OTAResponseTypes begin(size_t image_size); +#endif void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); @@ -33,8 +37,8 @@ class IDFOTABackend final { md5::MD5Digest md5_{}; char expected_bin_md5_[32]; bool md5_set_{false}; - ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; #ifdef USE_OTA_PARTITIONS + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; uint8_t buf_[OTA_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; From bf8604257f3efd0929e6ab7bcf2f2290f2759ab0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:00:18 +0000 Subject: [PATCH 08/70] [pre-commit.ci lite] apply automatic fixes --- esphome/components/ota/ota_backend_esp_idf.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index b23a2247e2..ca3042003c 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -191,9 +191,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *running_app_part = nullptr; if (running_app_size == 0) { running_app_part = esp_ota_get_running_partition(); - // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on a previous run. - // Cache the running app offset and size. - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; + // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on + // a previous run. Cache the running app offset and size. + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; running_app_offset = running_app_part->address; const esp_partition_pos_t running_app_pos = { .offset = running_app_part->address, @@ -206,8 +207,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { running_app_size = image_metadata.image_len; } // Align running_app_size to flash sectors - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; - ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", + running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); } // Get partition table partition @@ -309,7 +312,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } esp_partition_iterator_release(it); } - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + app_copy_target_part->address, running_app_size); #if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 // The following function takes longer than the 5 seconds timeout of WDT From 5a0cc68f6974e4f4fb499ca187112662570835b6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:37:34 +0200 Subject: [PATCH 09/70] Fix size calculation, deinit nvs --- esphome/components/ota/ota_backend_esp_idf.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ca3042003c..6074e63486 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -11,17 +11,13 @@ #ifdef USE_OTA_PARTITIONS #include +#include #endif namespace esphome::ota { static const char *const TAG = "ota.idf"; -#ifdef USE_OTA_PARTITIONS -static uint32_t running_app_offset = 0; -static size_t running_app_size = 0; -#endif - std::unique_ptr make_ota_backend() { return make_unique(); } #ifdef USE_OTA_PARTITIONS @@ -188,6 +184,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Get running app partition and used size + static uint32_t running_app_offset = 0; + static size_t running_app_size = 0; const esp_partition_t *running_app_part = nullptr; if (running_app_size == 0) { running_app_part = esp_ota_get_running_partition(); @@ -262,7 +260,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (new_part->pos.offset == running_app_offset) { app_index = i; } else if (new_part->pos.offset >= running_app_offset + running_app_size || - running_app_offset >= new_part->pos.offset + new_part->pos.size) { + running_app_offset >= new_part->pos.offset + running_app_size) { // New app partition has no overlap with running app esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { @@ -296,7 +294,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_UNKNOWN; } - ESP_LOGD(TAG, "Checks passed, starting partition table update", err); + ESP_LOGD(TAG, "Checks passed, starting partition table update"); + + // Deinitialize NVS to prevent unwanted flash writes + nvs_flash_deinit(); // Copy the running app partition to new position if needed if (app_index == -1) { @@ -383,8 +384,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_UNKNOWN; } - - ESP_LOGD(TAG, "Partition table updated successfully", err); return OTA_RESPONSE_OK; } #endif From 1dcf0bed5681dc057b23a47abe87ce6b91126d2b Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:58:48 +0200 Subject: [PATCH 10/70] Add dump_config and error messages --- .../components/esphome/ota/ota_esphome.cpp | 20 ++- esphome/components/ota/ota_backend.h | 2 + .../components/ota/ota_backend_esp_idf.cpp | 116 ++++++++++-------- esphome/components/ota/ota_backend_esp_idf.h | 4 + esphome/espota2.py | 18 +++ 5 files changed, 105 insertions(+), 55 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index bf03482451..e8626be9fb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,6 +100,25 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif +#ifdef USE_OTA_PARTITIONS + ESP_LOGCONFIG(TAG, " Partition access allowed"); + uint32_t running_app_offset; + size_t running_app_size; + ota::get_running_app_position(running_app_offset, running_app_size); + ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", + running_app_offset, running_app_size); +#ifdef USE_ESP32 + ESP_LOGCONFIG(TAG, " Partition table:"); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", + p->label, p->type, p->subtype, p->address, p->size); + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); +#endif +#endif } void ESPHomeOTAComponent::loop() { @@ -214,7 +233,6 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; - } else { #endif this->handshake_buf_[0] = diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 18e2fa3802..41dbe5fcda 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -40,6 +40,8 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E, + OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F, + OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 6074e63486..36a4741613 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -26,7 +26,8 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || image_size > OTA_BUFFER_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + ESP_LOGE(TAG, "Wrong partition table size"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } memset(this->buf_, 0xFF, sizeof this->buf_); this->buf_written_ = 0; @@ -98,7 +99,8 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { if (len > OTA_BUFFER_SIZE - this->buf_written_) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + ESP_LOGE(TAG, "Wrong partition table size"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } memcpy(this->buf_ + this->buf_written_, data, len); this->buf_written_ += len; @@ -176,49 +178,26 @@ void IDFOTABackend::abort() { #ifdef USE_OTA_PARTITIONS OTAResponseTypes IDFOTABackend::update_partition_table() { - esp_err_t err; int num_partitions; if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { - ESP_LOGE(TAG, "not enough data received (%d/%d bytes)", this->buf_written_, this->image_size_); - return OTA_RESPONSE_ERROR_UNKNOWN; + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Get running app partition and used size - static uint32_t running_app_offset = 0; - static size_t running_app_size = 0; - const esp_partition_t *running_app_part = nullptr; - if (running_app_size == 0) { - running_app_part = esp_ota_get_running_partition(); - // esp_ota_get_running_partition() returns a pointer to invalid data after esp_partition_unload_all() was called on - // a previous run. Cache the running app offset and size. - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; - running_app_offset = running_app_part->address; - const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, - }; - esp_image_metadata_t image_metadata; - image_metadata.start_addr = running_app_part->address; - err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); - if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { - running_app_size = image_metadata.image_len; - } - // Align running_app_size to flash sectors - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; - ESP_LOGD(TAG, "Running app: address=0x%X partition_size=0x%X used_size=0x%X, aligned_size=0x%X", - running_app_part->address, running_app_part->size, image_metadata.image_len, running_app_size); - } + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); // Get partition table partition - err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, + esp_err_t err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } + // Verify existing partition table const esp_partition_info_t *existing_partition_table = NULL; esp_partition_mmap_handle_t partition_table_map; @@ -226,13 +205,13 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { (const void **) &existing_partition_table, &partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); esp_partition_munmap(partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Verify new partition table @@ -241,7 +220,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Check if the required app and otadata partitions exist in the new partition table @@ -283,15 +262,15 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } if (app_index == -1 && app_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (app_partitions_found < 2 || otadata_index == -1) { ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (!otadata_no_overlap) { ESP_LOGE(TAG, "New otadata partition overlaps with running app"); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } ESP_LOGD(TAG, "Checks passed, starting partition table update"); @@ -301,18 +280,17 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Copy the running app partition to new position if needed if (app_index == -1) { - if (running_app_part == nullptr) { - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { - const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; - if (p->address == running_app_offset && p->size >= running_app_size) { - running_app_part = p; - } - it = esp_partition_next(it); + const esp_partition_t *running_app_part = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); + const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + if (p->address == running_app_offset && p->size >= running_app_size) { + running_app_part = p; } - esp_partition_iterator_release(it); + it = esp_partition_next(it); } + esp_partition_iterator_release(it); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); @@ -341,7 +319,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } } @@ -351,18 +329,18 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, this->image_size_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } esp_partition_unload_all(); @@ -382,10 +360,40 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); - return OTA_RESPONSE_ERROR_UNKNOWN; + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } return OTA_RESPONSE_OK; } + +void get_running_app_position(uint32_t &offset, size_t &size) { + // Gets the start address and the used length aligned to sectors of the running app. + // This function needs to be called once before calling esp_partition_unload_all(). + // The results are stored using static variables because esp_ota_get_running_partition() + // does not return valid data after calling esp_partition_unload_all(). + static uint32_t running_app_offset = 0; + static size_t running_app_size = 0; + if (running_app_size == 0) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + running_app_offset = running_app_part->address; + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata; + image_metadata.start_addr = running_app_part->address; + esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); + if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { + running_app_size = image_metadata.image_len; + } + // Align running_app_size to flash sectors + running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + } + offset = running_app_offset; + size = running_app_size; +} #endif } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 9d38d0d8d8..b1c49c427a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -13,6 +13,10 @@ namespace esphome::ota { static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif +#ifdef USE_OTA_PARTITIONS + void get_running_app_position(uint32_t &offset, size_t &size); +#endif + class IDFOTABackend final { public: #ifdef USE_OTA_PARTITIONS diff --git a/esphome/espota2.py b/esphome/espota2.py index f6ecb123b9..8b5138789e 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -46,6 +46,9 @@ RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A RESPONSE_ERROR_MD5_MISMATCH = 0x8B RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D +RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E +RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F +RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -206,6 +209,21 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "with the correct key. Ensure the signing key matches the one used to build " "the firmware currently running on the device." ) + if dat == RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: + raise OTAError( + "Error: The requested OTA type is not supported by the device." + ) + if dat == RESPONSE_ERROR_PARTITION_TABLE_VERIFY: + raise OTAError( + "Error: The partition table update could not be verified. No changes were " + "made to the flash content. Check the logs for more information and retry." + ) + if dat == RESPONSE_ERROR_PARTITION_TABLE_UPDATE: + raise OTAError( + "Error: An error occurred while updating the partition table. The device may not " + "be able to reboot to a working application. Check the logs and retry the update " + "without rebooting the device." + ) if dat == RESPONSE_ERROR_UNKNOWN: raise OTAError("Unknown error from ESP") if not isinstance(expect, (list, tuple)): From 2365431ebccd0748a620210749a3f77b409fe66c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:01:28 +0000 Subject: [PATCH 11/70] [pre-commit.ci lite] apply automatic fixes --- esphome/components/esphome/ota/ota_esphome.cpp | 8 ++++---- esphome/components/ota/ota_backend_esp_idf.cpp | 6 +++--- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/espota2.py | 4 +--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e8626be9fb..045aa133d6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -105,15 +105,15 @@ void ESPHomeOTAComponent::dump_config() { uint32_t running_app_offset; size_t running_app_size; ota::get_running_app_position(running_app_offset, running_app_size); - ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", - running_app_offset, running_app_size); + ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", running_app_offset, + running_app_size); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", - p->label, p->type, p->subtype, p->address, p->size); + ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", p->label, p->type, p->subtype, + p->address, p->size); it = esp_partition_next(it); } esp_partition_iterator_release(it); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 36a4741613..f5b8f14cfe 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -190,9 +190,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { get_running_app_position(running_app_offset, running_app_size); // Get partition table partition - esp_err_t err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, - "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, - ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + esp_err_t err = esp_partition_register_external( + nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", + ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index b1c49c427a..6192f5f969 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -14,7 +14,7 @@ static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif #ifdef USE_OTA_PARTITIONS - void get_running_app_position(uint32_t &offset, size_t &size); +void get_running_app_position(uint32_t &offset, size_t &size); #endif class IDFOTABackend final { diff --git a/esphome/espota2.py b/esphome/espota2.py index 8b5138789e..e8d27d69fa 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -210,9 +210,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "the firmware currently running on the device." ) if dat == RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: - raise OTAError( - "Error: The requested OTA type is not supported by the device." - ) + raise OTAError("Error: The requested OTA type is not supported by the device.") if dat == RESPONSE_ERROR_PARTITION_TABLE_VERIFY: raise OTAError( "Error: The partition table update could not be verified. No changes were " From e4669fefd5a84a293d0c844d2f940e55630dedc7 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:29:50 +0200 Subject: [PATCH 12/70] Update test --- tests/unit_tests/test_espota2.py | 46 ++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index ab0ad7034c..bab3ef492c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,6 +185,10 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + (espota2.RESPONSE_ERROR_SIGNATURE_INVALID, "Error: Firmware signature verification failed"), + (espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, "Error: The requested OTA type is not supported by the device"), + (espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, "Error: The partition table update could not be verified"), + (espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, "Error: An error occurred while updating the partition table"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -270,7 +274,7 @@ def test_perform_ota_successful_md5_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ @@ -641,7 +645,7 @@ def test_perform_ota_successful_sha256_auth( # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent (compression + SHA256 support) + # Verify features were sent (compression + SHA256 support + extended protocol) assert mock_socket.sendall.call_args_list[1] == call( bytes( [ @@ -768,3 +772,41 @@ def test_perform_ota_version_differences( # For v2.0, verify more recv calls due to chunk acknowledgments assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA partition table update.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol + bytes([espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS]), # Device feature flags + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "testpass", mock_file, "partitions.bin", espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support + extended protocol) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + ] + ) + ) + + # Verify ota type was sent + assert mock_socket.sendall.call_args_list[2] == call(bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE)) From 81294b4c8058ff113c871fe17ae44f47b9cd76ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:31:08 +0000 Subject: [PATCH 13/70] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_espota2.py | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index bab3ef492c..19b4d85b99 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -185,10 +185,22 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: "Error: The OTA partition on the ESP couldn't be found", ), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), - (espota2.RESPONSE_ERROR_SIGNATURE_INVALID, "Error: Firmware signature verification failed"), - (espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, "Error: The requested OTA type is not supported by the device"), - (espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, "Error: The partition table update could not be verified"), - (espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, "Error: An error occurred while updating the partition table"), + ( + espota2.RESPONSE_ERROR_SIGNATURE_INVALID, + "Error: Firmware signature verification failed", + ), + ( + espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, + "Error: The requested OTA type is not supported by the device", + ), + ( + espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, + "Error: The partition table update could not be verified", + ), + ( + espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, + "Error: An error occurred while updating the partition table", + ), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -775,13 +787,20 @@ def test_perform_ota_version_differences( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io.BytesIO) -> None: +def test_perform_ota_successful_partition_table( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: """Test OTA partition table update.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol - bytes([espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS]), # Device feature flags + bytes( + [ + espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION + | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ] + ), # Device feature flags bytes([espota2.RESPONSE_AUTH_OK]), # No auth required bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -792,7 +811,13 @@ def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "testpass", mock_file, "partitions.bin", espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, + ) # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) @@ -809,4 +834,6 @@ def test_perform_ota_successful_partition_table(mock_socket: Mock, mock_file: io ) # Verify ota type was sent - assert mock_socket.sendall.call_args_list[2] == call(bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE)) + assert mock_socket.sendall.call_args_list[2] == call( + bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + ) From 346333dcf973bfd7a0a616a4d34da6949123dd22 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:48:33 +0200 Subject: [PATCH 14/70] Update test --- tests/unit_tests/test_espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 19b4d85b99..7d6f629195 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -835,5 +835,5 @@ def test_perform_ota_successful_partition_table( # Verify ota type was sent assert mock_socket.sendall.call_args_list[2] == call( - bytes(espota2.OTA_TYPE_UPDATE_PARTITION_TABLE) + bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE]) ) From dc37bd2ab4937ebf69117a4822b190c130dd36bf Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:30:54 +0200 Subject: [PATCH 15/70] Update test --- tests/components/ota/test.esp32-idf.yaml | 4 ++ tests/unit_tests/test_espota2.py | 24 ++++++++++++ tests/unit_tests/test_main.py | 47 ++++++++++++++++++++---- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index dade44d145..0cbf854952 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + <<: !include common.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 7d6f629195..9ccc459a42 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -837,3 +837,27 @@ def test_perform_ota_successful_partition_table( assert mock_socket.sendall.call_args_list[2] == call( bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE]) ) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_extended_protocol_unsupported( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA fails when extended protocol is required but unsupported.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Device only supports app updates"): + espota2.perform_ota( + mock_socket, + "testpass", + mock_file, + "partitions.bin", + espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e71c735f2e..ece668b618 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -83,6 +83,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE from esphome.util import BootselResult from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1111,6 +1112,7 @@ class MockArgs: reset: bool = False list_only: bool = False output: str | None = None + partition_table: bool = False def test_upload_program_serial_esp32( @@ -1593,7 +1595,7 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware, 0 + ["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1624,7 +1626,38 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin"), 0 + ["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP + ) + + +def test_upload_program_ota_partition_table_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and partition table.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="partitions.bin", partition_table=True) + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, None, Path("partitions.bin"), OTA_TYPE_UPDATE_PARTITION_TABLE ) @@ -1682,7 +1715,7 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, 0 + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -1730,7 +1763,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware, 0 + ["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -3207,7 +3240,7 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, 0 + ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) @@ -3254,7 +3287,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( 3232, None, expected_firmware, - 0, + OTA_TYPE_UPDATE_APP, ) @@ -3419,7 +3452,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, 0 + ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP ) From a6c27dcb4b14fd38ed5eefae722dcb72b77140e6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:32:15 +0000 Subject: [PATCH 16/70] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_main.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ece668b618..bfecd03ea4 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1657,7 +1657,11 @@ def test_upload_program_ota_partition_table_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("partitions.bin"), OTA_TYPE_UPDATE_PARTITION_TABLE + ["192.168.1.100"], + 3232, + None, + Path("partitions.bin"), + OTA_TYPE_UPDATE_PARTITION_TABLE, ) @@ -3240,7 +3244,11 @@ def test_upload_program_ota_static_ip_with_mqttip( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP + ["192.168.1.100", "192.168.2.50"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, ) From c096e9bfee0822bf21ebc61d6c98f2e95bfb5a6c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:23:02 +0200 Subject: [PATCH 17/70] Use WatchdogManager, check for missing checksum in partition table --- esphome/components/ota/__init__.py | 2 ++ .../components/ota/ota_backend_esp_idf.cpp | 36 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd..579491fe1a 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -24,6 +24,8 @@ def AUTO_LOAD() -> list[str]: components = ["safe_mode"] if not CORE.using_zephyr: components.extend(["md5"]) + if CORE.is_esp32: + components.extend(["watchdog"]) return components diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f5b8f14cfe..7200eb752e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -10,6 +10,7 @@ #include #ifdef USE_OTA_PARTITIONS +#include "esphome/components/watchdog/watchdog.h" #include #include #endif @@ -222,6 +223,18 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } + // Check for missing checksum + // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update + bool checksum_found = false; + for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { + if (((const esp_partition_info_t *)&new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { + checksum_found = true; + } + } + if (!checksum_found) { + ESP_LOGE(TAG, "New partition table has no checksum", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } // Check if the required app and otadata partitions exist in the new partition table // Check which app slot to boot from in the new partition table @@ -284,7 +297,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; if (p->address == running_app_offset && p->size >= running_app_size) { running_app_part = p; } @@ -294,29 +306,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // The following function takes longer than the 5 seconds timeout of WDT - 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); -#endif - + watchdog::WatchdogManager watchdog(15000); err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); -#if CONFIG_ESP_TASK_WDT_TIMEOUT_S < 15 - // Set the WDT back to the configured timeout - wdtc.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; - esp_task_wdt_reconfigure(&wdtc); -#endif - if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; From d03f0bccf0ee1ffd4af24526c7bc870acd387584 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:24:15 +0000 Subject: [PATCH 18/70] [pre-commit.ci lite] apply automatic fixes --- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 7200eb752e..269d9153b2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -227,7 +227,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { - if (((const esp_partition_info_t *)&new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { + if (((const esp_partition_info_t *) &new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { checksum_found = true; } } From eec9e907322de94af34de240c4ca56222ac5c268 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:34:38 +0200 Subject: [PATCH 19/70] Make code more readable --- .../components/esphome/ota/ota_esphome.cpp | 16 ++++--- .../components/ota/ota_backend_esp_idf.cpp | 48 +++++++++++-------- .../ota/test-partition_access.esp32-idf.yaml | 5 ++ tests/components/ota/test.esp32-idf.yaml | 4 -- 4 files changed, 42 insertions(+), 31 deletions(-) create mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 045aa133d6..f9133a515f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -223,20 +223,22 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); + + const bool supports_compression = ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && + this->backend_->supports_compression()); #ifdef USE_OTA_PARTITIONS this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; if (this->extended_proto_) { - this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = 0; - if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && - this->backend_->supports_compression()) { + // If the client supports the extended protocol, send 2 bytes: response type and server feature flags + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags + this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS + if (supports_compression) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } - this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; } else { #endif - this->handshake_buf_[0] = - ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + // Standard protocol without server feature flags + this->handshake_buf_[0] = (supports_compression) ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 269d9153b2..3e79b69e67 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -178,6 +178,10 @@ void IDFOTABackend::abort() { } #ifdef USE_OTA_PARTITIONS +static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { + return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); +} + OTAResponseTypes IDFOTABackend::update_partition_table() { int num_partitions; if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { @@ -239,28 +243,32 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Check if the required app and otadata partitions exist in the new partition table // Check which app slot to boot from in the new partition table int app_partitions_found = 0; - int app_index = -1; - int app_index_with_copy = -1; - int otadata_index = -1; - bool otadata_no_overlap = false; + int new_app_part_index = -1; + int new_app_part_index_with_copy = -1; + int new_otadata_part_index = -1; + bool otadata_overlap = true; const esp_partition_t *app_copy_target_part{nullptr}; - for (int i = 0; i < num_partitions; i++) { + for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { + // Found an app partition in the new partition table app_partitions_found++; if (new_part->pos.size >= running_app_size) { + // Running app can fit inside this partition if (new_part->pos.offset == running_app_offset) { - app_index = i; - } else if (new_part->pos.offset >= running_app_offset + running_app_size || - running_app_offset >= new_part->pos.offset + running_app_size) { - // New app partition has no overlap with running app + // This new app partition can be used for the running app without copying because the offsets are the same + new_app_part_index = i; + } else if (!check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // This new app partition can be used for the running app after copying the app into it + // Check if there is an app partition in the old partition table at the right offset + // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); if (p->address == new_part->pos.offset && p->size >= running_app_size) { - // App partition exists in old and new partition table - app_index_with_copy = i; - app_copy_target_part = p; + // Found a suitable pair of partitions in the old and new partition table to copy the running app to + new_app_part_index_with_copy = i; // The partition index in the new partition table + app_copy_target_part = p; // The partition in the old partition table } it = esp_partition_next(it); } @@ -268,20 +276,20 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } } else if (new_part->type == ESP_PARTITION_TYPE_DATA && new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - otadata_index = i; - otadata_no_overlap = new_part->pos.offset >= running_app_offset + running_app_size || - running_app_offset >= new_part->pos.offset + new_part->pos.size; + // Found the otadata partition in the new partition table + new_otadata_part_index = i; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); } } - if (app_index == -1 && app_index_with_copy == -1) { + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (app_partitions_found < 2 || otadata_index == -1) { + if (app_partitions_found < 2 || new_otadata_part_index == -1) { ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (!otadata_no_overlap) { + if (otadata_overlap) { ESP_LOGE(TAG, "New otadata partition overlaps with running app"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -292,7 +300,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { nvs_flash_deinit(); // Copy the running app partition to new position if needed - if (app_index == -1) { + if (new_app_part_index == -1) { const esp_partition_t *running_app_part = nullptr; esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { @@ -341,7 +349,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[app_index == -1 ? app_index_with_copy : app_index]; + const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; if (p->address == new_part->pos.offset) { new_boot_partition = p; } diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml new file mode 100644 index 0000000000..0cbf854952 --- /dev/null +++ b/tests/components/ota/test-partition_access.esp32-idf.yaml @@ -0,0 +1,5 @@ +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index 0cbf854952..dade44d145 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1,5 +1 @@ -ota: - - platform: esphome - allow_partition_access: true - <<: !include common.yaml From 2af5c4d82ce0d4fbad738f5e99807656e4ee9b1e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:35:46 +0000 Subject: [PATCH 20/70] [pre-commit.ci lite] apply automatic fixes --- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++------- esphome/components/ota/ota_backend_esp_idf.cpp | 7 ++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f9133a515f..e5db0df462 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -224,23 +224,23 @@ void ESPHomeOTAComponent::handle_handshake_() { ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); - const bool supports_compression = ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && - this->backend_->supports_compression()); + const bool supports_compression = + ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()); #ifdef USE_OTA_PARTITIONS this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; if (this->extended_proto_) { // If the client supports the extended protocol, send 2 bytes: response type and server feature flags - this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags - this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS + this->handshake_buf_[0] = + ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags + this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS if (supports_compression) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; } } else { #endif // Standard protocol without server feature flags - this->handshake_buf_[0] = (supports_compression) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; + this->handshake_buf_[0] = + (supports_compression) ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; #ifdef USE_OTA_PARTITIONS } #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 3e79b69e67..6a2afac978 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -179,7 +179,7 @@ void IDFOTABackend::abort() { #ifdef USE_OTA_PARTITIONS static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { - return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); + return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); } OTAResponseTypes IDFOTABackend::update_partition_table() { @@ -268,7 +268,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (p->address == new_part->pos.offset && p->size >= running_app_size) { // Found a suitable pair of partitions in the old and new partition table to copy the running app to new_app_part_index_with_copy = i; // The partition index in the new partition table - app_copy_target_part = p; // The partition in the old partition table + app_copy_target_part = p; // The partition in the old partition table } it = esp_partition_next(it); } @@ -349,7 +349,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; + const esp_partition_info_t *new_part = + &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; if (p->address == new_part->pos.offset) { new_boot_partition = p; } From e1fbb6ac5a2d61fb9e6b263b1771f36f076539a6 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:40:00 +0200 Subject: [PATCH 21/70] Validate nvs partition, only allow partition-table option for OTA updates --- esphome/__main__.py | 7 +++++- .../components/esphome/ota/ota_esphome.cpp | 3 +-- .../components/ota/ota_backend_esp_idf.cpp | 24 ++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d0d748a1ec..411108e554 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1078,6 +1078,11 @@ def upload_program( port_type = get_port_type(host) + if port_type != PortType.NETWORK and getattr(args, "partition_table", False): + raise EsphomeError( + f"The option --partition-table can only be used for Over The Air updates." + ) + if port_type == PortType.BOOTSEL: exit_code = upload_using_picotool(config) # Return None for device - BOOTSEL can't be used for logging, @@ -1788,7 +1793,7 @@ def parse_args(argv): ) parser_upload.add_argument( "--partition-table", - help="Upload as partition table", + help="Upload as partition table (OTA).", action="store_true", ) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e5db0df462..91aee0cea6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -230,8 +230,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; if (this->extended_proto_) { // If the client supports the extended protocol, send 2 bytes: response type and server feature flags - this->handshake_buf_[0] = - ota::OTA_RESPONSE_FEATURE_FLAGS; // indicates the following byte contains feature flags + this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS if (supports_compression) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 6a2afac978..2118234ae8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -245,9 +245,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; - int new_otadata_part_index = -1; - bool otadata_overlap = true; - const esp_partition_t *app_copy_target_part{nullptr}; + const esp_partition_t *app_copy_target_part = nullptr; + bool otadata_partition_found = false; + bool otadata_overlap = false; + bool nvs_partition_found = false; for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { @@ -275,18 +276,23 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_iterator_release(it); } } - } else if (new_part->type == ESP_PARTITION_TYPE_DATA && new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - // Found the otadata partition in the new partition table - new_otadata_part_index = i; - otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { + if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + // Found the otadata partition in the new partition table + otadata_partition_found = true; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char*)new_part->label, "nvs") == 0) { + // Found the nvs partition in the new partition table + nvs_partition_found = true; + } } } if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (app_partitions_found < 2 || new_otadata_part_index == -1) { - ESP_LOGE(TAG, "New partition table is missing the required app or otadata partitions"); + if (app_partitions_found < 2 || !otadata_partition_found || !nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required app, otadata or nvs partitions"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { From ef422304127ffa52b6e9a57d7f4cabb3fcbc5d3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:41:16 +0000 Subject: [PATCH 22/70] [pre-commit.ci lite] apply automatic fixes --- esphome/__main__.py | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 411108e554..c931eb179d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1080,7 +1080,7 @@ def upload_program( if port_type != PortType.NETWORK and getattr(args, "partition_table", False): raise EsphomeError( - f"The option --partition-table can only be used for Over The Air updates." + "The option --partition-table can only be used for Over The Air updates." ) if port_type == PortType.BOOTSEL: diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2118234ae8..85c430cac6 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -281,7 +281,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Found the otadata partition in the new partition table otadata_partition_found = true; otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); - } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char*)new_part->label, "nvs") == 0) { + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char *) new_part->label, "nvs") == 0) { // Found the nvs partition in the new partition table nvs_partition_found = true; } From 8b3e4dcc475b16c59287322123fef1ae83585608 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:07:00 +0200 Subject: [PATCH 23/70] Update test --- tests/unit_tests/test_main.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bfecd03ea4..fc526745f6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1665,6 +1665,23 @@ def test_upload_program_ota_partition_table_with_file_arg( ) +def test_upload_program_serial_partition_table( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, +) -> None: + """Test serial upload with partition table option (unsupported).""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs(partition_table=True) + devices = ["/dev/ttyUSB0"] + + with pytest.raises(EsphomeError, match="The option --partition-table can only be used for Over The Air updates"): + upload_program(config, args, devices) + + def test_upload_program_ota_no_config( mock_get_port_type: Mock, ) -> None: From 51baefcd24dbb53f18da6890ab3e06a1435853d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:08:49 +0000 Subject: [PATCH 24/70] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fc526745f6..fdfa0f5d5f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1678,7 +1678,10 @@ def test_upload_program_serial_partition_table( args = MockArgs(partition_table=True) devices = ["/dev/ttyUSB0"] - with pytest.raises(EsphomeError, match="The option --partition-table can only be used for Over The Air updates"): + with pytest.raises( + EsphomeError, + match="The option --partition-table can only be used for Over The Air updates", + ): upload_program(config, args, devices) From 0728b5284efa048e4dfaa58fd356de5807f98f08 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:47:01 +0200 Subject: [PATCH 25/70] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- esphome/components/esphome/ota/ota_esphome.h | 2 ++ esphome/components/ota/ota_backend_esp_idf.cpp | 15 +++++++-------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 91aee0cea6..0e242f5e74 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,6 +87,9 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif +#ifdef USE_OTA_PARTITIONS + ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -102,11 +105,8 @@ void ESPHomeOTAComponent::dump_config() { #endif #ifdef USE_OTA_PARTITIONS ESP_LOGCONFIG(TAG, " Partition access allowed"); - uint32_t running_app_offset; - size_t running_app_size; - ota::get_running_app_position(running_app_offset, running_app_size); - ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %d bytes", running_app_offset, - running_app_size); + ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %zu bytes", this->running_app_offset_, + this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 9bed9240aa..c09ad843f8 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -93,6 +93,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD #ifdef USE_OTA_PARTITIONS bool extended_proto_{false}; + uint32_t running_app_offset_{0}; + size_t running_app_size_{0}; #endif socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 85c430cac6..d09f4c48d4 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -25,8 +25,7 @@ std::unique_ptr make_ota_backend() { return make_uniqueota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (image_size > ESP_PARTITION_TABLE_SIZE || image_size > ESP_PARTITION_TABLE_MAX_LEN || - image_size > OTA_BUFFER_SIZE) { + if (image_size > ESP_PARTITION_TABLE_MAX_LEN) { ESP_LOGE(TAG, "Wrong partition table size"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -236,7 +235,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } if (!checksum_found) { - ESP_LOGE(TAG, "New partition table has no checksum", err); + ESP_LOGE(TAG, "New partition table has no checksum"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -300,7 +299,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - ESP_LOGD(TAG, "Checks passed, starting partition table update"); + ESP_LOGW(TAG, "Checks passed, starting partition table update. Don't remove power until it is completed!"); // Deinitialize NVS to prevent unwanted flash writes nvs_flash_deinit(); @@ -330,14 +329,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Update the partition table - err = esp_ota_begin(this->partition_table_part_, this->image_size_, &this->update_handle_); + err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - err = esp_ota_write(this->update_handle_, this->buf_, this->image_size_); + err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; @@ -348,6 +347,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + this->partition_table_part_ = nullptr; esp_partition_unload_all(); // Write otadata to set the new boot partition @@ -381,8 +381,7 @@ void get_running_app_position(uint32_t &offset, size_t &size) { static size_t running_app_size = 0; if (running_app_size == 0) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; + running_app_size = running_app_part->size; running_app_offset = running_app_part->address; const esp_partition_pos_t running_app_pos = { .offset = running_app_part->address, From 6b5930e6cb7e89ff0985fd98ea659bf55b2d8eab Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:18:33 +0200 Subject: [PATCH 26/70] Apply suggestions --- esphome/core/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index c37b3be585..d97944fd2c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -780,7 +780,8 @@ class EsphomeCore: return self.relative_pioenvs_path(self.name, "firmware.bin") @property - def partition_table_bin(self): + def partition_table_bin(self) -> Path: + # native ESP-IDF: self.relative_build_path("build", "partition_table", "partition-table.bin") return self.relative_pioenvs_path(self.name, "partitions.bin") @property From fea26dab2083cfd0cb3a9d43829919925a24454c Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:24:51 +0200 Subject: [PATCH 27/70] Apply suggestions --- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index b55d66ba0a..0a9a76e771 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -77,7 +77,7 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if config.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: raise cv.Invalid( f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" ) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index d09f4c48d4..a378adb0e3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -347,6 +347,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; esp_partition_unload_all(); @@ -358,12 +359,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; if (p->address == new_part->pos.offset) { + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", p->address); new_boot_partition = p; } it = esp_partition_next(it); } esp_partition_iterator_release(it); - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); From 990d28fe0e6622abe7c492e6802572851b5d7fd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:29:09 -0500 Subject: [PATCH 28/70] safety --- esphome/components/ota/ota_backend_esp_idf.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index a378adb0e3..141cfcdeab 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -316,6 +316,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { it = esp_partition_next(it); } esp_partition_iterator_release(it); + if (running_app_part == nullptr) { + ESP_LOGE(TAG, "Running app partition not found in current partition table"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); @@ -365,6 +369,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { it = esp_partition_next(it); } esp_partition_iterator_release(it); + if (new_boot_partition == nullptr) { + ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); From d733b0b516aecbef895f643fd30a256629c8801a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:29:38 -0500 Subject: [PATCH 29/70] replace c-casts --- esphome/components/ota/ota_backend_esp_idf.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 141cfcdeab..70a2d14aae 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -280,7 +280,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Found the otadata partition in the new partition table otadata_partition_found = true; otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); - } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strcmp((char *) new_part->label, "nvs") == 0) { + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && + strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { // Found the nvs partition in the new partition table nvs_partition_found = true; } From 1f432d9d8a8820d2e2ad368ae7c6302e2b9d2292 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:30:51 -0500 Subject: [PATCH 30/70] hold watchdog for part write, could timeout and brick --- esphome/components/ota/ota_backend_esp_idf.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 70a2d14aae..503a1fbbe6 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -305,6 +305,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Deinitialize NVS to prevent unwanted flash writes nvs_flash_deinit(); + // Hold the watchdog open for the entire critical section: optional app copy, partition-table + // erase/write, and boot partition selection. None of the steps below should yield long enough + // to require a refresh, but bundling them under a single guard avoids spurious resets if the + // underlying ESP-IDF calls take longer than expected on a given chip variant. + watchdog::WatchdogManager watchdog(15000); + // Copy the running app partition to new position if needed if (new_app_part_index == -1) { const esp_partition_t *running_app_part = nullptr; @@ -324,7 +330,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); - watchdog::WatchdogManager watchdog(15000); err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { From 8b98079fad4847f315769ccbc45e592c4cfd23c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:33:11 -0500 Subject: [PATCH 31/70] first match wins --- esphome/components/ota/ota_backend_esp_idf.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 503a1fbbe6..15b83e400e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -248,6 +248,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { bool otadata_partition_found = false; bool otadata_overlap = false; bool nvs_partition_found = false; + // Selection policy when multiple app slots in the new partition table can host the running app: + // pick the FIRST eligible slot in table order. The no-copy path (offsets already match) is + // preferred over the copy path; within each path we lock in the first match and stop searching. + // This keeps the choice deterministic and table-ordering-stable instead of "last writer wins". for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { @@ -256,12 +260,17 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (new_part->pos.size >= running_app_size) { // Running app can fit inside this partition if (new_part->pos.offset == running_app_offset) { - // This new app partition can be used for the running app without copying because the offsets are the same - new_app_part_index = i; - } else if (!check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // This new app partition can be used for the running app without copying because the offsets are the same. + // First match wins; once locked in, the no-copy path is preferred and won't be overwritten. + if (new_app_part_index == -1) { + new_app_part_index = i; + } + } else if (new_app_part_index_with_copy == -1 && + !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { // This new app partition can be used for the running app after copying the app into it // Check if there is an app partition in the old partition table at the right offset - // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future + // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future. + // First match wins for determinism; stop searching as soon as a suitable pair is found. esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it != NULL) { const esp_partition_t *p = esp_partition_get(it); @@ -269,6 +278,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Found a suitable pair of partitions in the old and new partition table to copy the running app to new_app_part_index_with_copy = i; // The partition index in the new partition table app_copy_target_part = p; // The partition in the old partition table + break; } it = esp_partition_next(it); } From 9b3f8f6f6e66501ebc967b788705dcf5b83d7ba5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:25 -0500 Subject: [PATCH 32/70] explict name --- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.h | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 15b83e400e..2a08bece9a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -98,7 +98,7 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) { OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (len > OTA_BUFFER_SIZE - this->buf_written_) { + if (len > PARTITION_TABLE_BUFFER_SIZE - this->buf_written_) { ESP_LOGE(TAG, "Wrong partition table size"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 6192f5f969..d9de8a5eed 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,7 +10,10 @@ namespace esphome::ota { #ifdef USE_OTA_PARTITIONS -static constexpr size_t OTA_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 +// Dedicated staging buffer size for the new partition table image. Must be at least +// ESP_PARTITION_TABLE_MAX_LEN (0xC00) so the entire partition table fits before verification. +// Kept separate from any OTA chunk-transfer buffer to avoid coupling unrelated sizes. +static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 #endif #ifdef USE_OTA_PARTITIONS @@ -43,7 +46,7 @@ class IDFOTABackend final { bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; - uint8_t buf_[OTA_BUFFER_SIZE]; + uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; const esp_partition_t *partition_table_part_{nullptr}; From 694f1947ee499f996e949c5749207115067b6c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:39 -0500 Subject: [PATCH 33/70] convention is nulltr --- esphome/components/esphome/ota/ota_esphome.cpp | 4 ++-- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1d0b8713a4..245bdc9a09 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -109,8 +109,8 @@ void ESPHomeOTAComponent::dump_config() { this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", p->label, p->type, p->subtype, p->address, p->size); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2a08bece9a..bfd469bd73 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -203,7 +203,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify existing partition table - const esp_partition_info_t *existing_partition_table = NULL; + const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, (const void **) &existing_partition_table, &partition_table_map); From eff13bedd5da52fb0e356ac6f2b4b88fa20ddb39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:46 -0500 Subject: [PATCH 34/70] convention is nulltr --- esphome/components/ota/ota_backend_esp_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index bfd469bd73..8cf788da4d 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -271,8 +271,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Check if there is an app partition in the old partition table at the right offset // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future. // First match wins for determinism; stop searching as soon as a suitable pair is found. - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); if (p->address == new_part->pos.offset && p->size >= running_app_size) { // Found a suitable pair of partitions in the old and new partition table to copy the running app to From b649850e806ae1e0d8870177671565bcd6aa2217 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:51 -0500 Subject: [PATCH 35/70] convention is nulltr --- esphome/components/ota/ota_backend_esp_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 8cf788da4d..441953497f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -324,8 +324,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Copy the running app partition to new position if needed if (new_app_part_index == -1) { const esp_partition_t *running_app_part = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); if (p->address == running_app_offset && p->size >= running_app_size) { running_app_part = p; From 582ccf1cd1e24b0d9064fa891b2245e6c145ab4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:34:56 -0500 Subject: [PATCH 36/70] convention is nulltr --- esphome/components/ota/ota_backend_esp_idf.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 441953497f..5c048f9add 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -373,8 +373,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Write otadata to set the new boot partition const esp_partition_t *new_boot_partition = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); - while (it != NULL) { + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { const esp_partition_t *p = esp_partition_get(it); const esp_partition_info_t *new_part = &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; From 306edfcd8aaca5895df1c2c70d61c28ebf18bd8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:36:40 -0500 Subject: [PATCH 37/70] follow https://developers.esphome.io/architecture/logging/#configuration-logging-esp_logconfig --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 245bdc9a09..ceea617c58 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -104,9 +104,12 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS - ESP_LOGCONFIG(TAG, " Partition access allowed"); - ESP_LOGCONFIG(TAG, " Running app:\n Partition address: 0x%X\n Used size: %zu bytes", this->running_app_offset_, - this->running_app_size_); + ESP_LOGCONFIG(TAG, + " Partition access allowed\n" + " Running app:\n" + " Partition address: 0x%X\n" + " Used size: %zu bytes", + this->running_app_offset_, this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); From 863ecde7236cdb8c36b6bb25f5781bc1ac3e609c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:37:13 -0500 Subject: [PATCH 38/70] use cpp casts --- esphome/components/ota/ota_backend_esp_idf.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 5c048f9add..d5e95bd111 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -206,7 +206,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, - (const void **) &existing_partition_table, &partition_table_map); + reinterpret_cast(&existing_partition_table), &partition_table_map); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -219,7 +219,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify new partition table - const esp_partition_info_t *new_partition_table = (const esp_partition_info_t *) this->buf_; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { @@ -230,7 +230,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { - if (((const esp_partition_info_t *) &new_partition_table[i])->magic == ESP_PARTITION_MAGIC_MD5) { + if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { checksum_found = true; } } From 869d900ba6456fdffc0f32d491d8b851d783d434 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:37:57 -0500 Subject: [PATCH 39/70] constexpr --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ceea617c58..b8484bfce7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -136,11 +136,11 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } -static const uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; -static const uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; -static const uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; -static const uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. From a97b591c8c2f63c1dd82006f4eb55615478756d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:38:53 -0500 Subject: [PATCH 40/70] align --- esphome/components/esphome/ota/ota_esphome.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c09ad843f8..2da6e7dcd6 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -92,9 +92,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_PARTITIONS - bool extended_proto_{false}; + // 4-byte members first, 1-byte member last to minimize padding. uint32_t running_app_offset_{0}; size_t running_app_size_{0}; + bool extended_proto_{false}; #endif socket::ListenSocket *server_{nullptr}; From 204f52a2e68b95260617cb224ad96298ab631855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:39:49 -0500 Subject: [PATCH 41/70] dry --- .../components/ota/ota_backend_esp_idf.cpp | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index d5e95bd111..9a97fd8d8b 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -181,6 +181,24 @@ static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_of return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); } +// Find the first registered APP partition whose address matches `address` and whose size is at least +// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so +// callers don't have to repeat (and correctly handle) the find/get/next/release dance. +static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { + const esp_partition_t *found = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == address && p->size >= min_size) { + found = p; + break; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + return found; +} + OTAResponseTypes IDFOTABackend::update_partition_table() { int num_partitions; if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { @@ -267,22 +285,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // This new app partition can be used for the running app after copying the app into it + // This new app partition can be used for the running app after copying the app into it. // Check if there is an app partition in the old partition table at the right offset - // This is for esp_partition_copy and won't be needed after implementing a better copy function in the future. - // First match wins for determinism; stop searching as soon as a suitable pair is found. - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - if (p->address == new_part->pos.offset && p->size >= running_app_size) { - // Found a suitable pair of partitions in the old and new partition table to copy the running app to - new_app_part_index_with_copy = i; // The partition index in the new partition table - app_copy_target_part = p; // The partition in the old partition table - break; - } - it = esp_partition_next(it); + // (esp_partition_copy needs a registered source partition; first match wins for determinism). + const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); + if (p != nullptr) { + new_app_part_index_with_copy = i; // The partition index in the new partition table + app_copy_target_part = p; // The partition in the old partition table } - esp_partition_iterator_release(it); } } } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { @@ -323,16 +333,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Copy the running app partition to new position if needed if (new_app_part_index == -1) { - const esp_partition_t *running_app_part = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - if (p->address == running_app_offset && p->size >= running_app_size) { - running_app_part = p; - } - it = esp_partition_next(it); - } - esp_partition_iterator_release(it); + const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); if (running_app_part == nullptr) { ESP_LOGE(TAG, "Running app partition not found in current partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; @@ -372,23 +373,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_partition_unload_all(); // Write otadata to set the new boot partition - const esp_partition_t *new_boot_partition = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - const esp_partition_info_t *new_part = - &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; - if (p->address == new_part->pos.offset) { - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", p->address); - new_boot_partition = p; - } - it = esp_partition_next(it); - } - esp_partition_iterator_release(it); + const esp_partition_info_t *new_part = + &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; + const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); From 734fc6187993d991ff73cc05b8a077cd41a73a76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:41:27 -0500 Subject: [PATCH 42/70] cleaups --- esphome/components/ota/ota_backend_esp_idf.cpp | 11 +++++------ esphome/components/ota/ota_backend_esp_idf.h | 5 ++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 9a97fd8d8b..3eff46dc0f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -331,13 +331,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // underlying ESP-IDF calls take longer than expected on a given chip variant. watchdog::WatchdogManager watchdog(15000); - // Copy the running app partition to new position if needed + // Copy the running app partition to new position if needed. + // esp_ota_get_running_partition() is still valid here (we have not yet called + // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would + // have located, without an extra iterator walk. if (new_app_part_index == -1) { - const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); - if (running_app_part == nullptr) { - ESP_LOGE(TAG, "Running app partition not found in current partition table"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, app_copy_target_part->address, running_app_size); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index d9de8a5eed..08d4ac2abe 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -45,11 +45,14 @@ class IDFOTABackend final { char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS - ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; + // Place the byte buffer first so it sits immediately after the preceding `bool md5_set_`, + // eliminating the 3-byte alignment padding that an int-sized member would otherwise force. + // Remaining members are 4-byte-aligned and pack tightly after the buffer. uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; const esp_partition_t *partition_table_part_{nullptr}; + ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP}; #endif }; From ab6eb247fa535476a250f96630efe48ea26111f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:46:51 -0500 Subject: [PATCH 43/70] fix handle leak on fail --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 3eff46dc0f..52dc4756a8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -168,10 +168,11 @@ void IDFOTABackend::abort() { esp_partition_deregister_external(this->partition_table_part_); this->partition_table_part_ = nullptr; } - if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) { - return; - } #endif + // Always tear down any open OTA handle. update_partition_table() opens a handle internally to + // write the new partition table; if esp_ota_write/esp_ota_end fail mid-flight, the handle must + // be released here so it isn't leaked. esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG + // harmlessly, so the unconditional call is safe whether or not we're mid-update. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; } @@ -358,11 +359,15 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); if (err != ESP_OK) { + // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the + // partial-write failure path self-contained. + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; + this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; From e5f3a91e42e11e3eb4a4f32330947acc79dc0a43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:48:45 -0500 Subject: [PATCH 44/70] falsey is default --- esphome/components/esphome/ota/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 0a9a76e771..ee3b7f0c20 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -77,7 +77,7 @@ def ota_esphome_final_validate(config): merged_ota_esphome_configs_by_port[conf_port] = merge_config( merged_ota_esphome_configs_by_port[conf_port], ota_conf ) - if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS, False) and not CORE.is_esp32: + if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32: raise cv.Invalid( f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32" ) @@ -167,7 +167,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) - if config.get(CONF_ALLOW_PARTITION_ACCESS, False): + if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. From 16ff803a3c3da3e19abde657643b726d0575867e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:49:41 -0500 Subject: [PATCH 45/70] improve readability --- .../components/esphome/ota/ota_esphome.cpp | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index b8484bfce7..6897165f1d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -228,38 +228,36 @@ void ESPHomeOTAComponent::handle_handshake_() { this->transition_ota_state_(OTAState::FEATURE_ACK); const bool supports_compression = - ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()); + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + + // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client + // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); + // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto + // branch so the legacy branch reads as unconditional code in either build configuration. #ifdef USE_OTA_PARTITIONS - this->extended_proto_ = this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; + this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { - // If the client supports the extended protocol, send 2 bytes: response type and server feature flags this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; // supported if USE_OTA_PARTITIONS - if (supports_compression) { - this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_COMPRESSION; - } - } else { + this->handshake_buf_[1] = + SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); + } else #endif - // Standard protocol without server feature flags + { this->handshake_buf_[0] = - (supports_compression) ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; -#ifdef USE_OTA_PARTITIONS + supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; } -#endif [[fallthrough]]; } case OTAState::FEATURE_ACK: { - // Acknowledge header - 1 byte #ifdef USE_OTA_PARTITIONS - if (!this->try_write_(this->extended_proto_ ? 2 : 1, LOG_STR("ack feature"))) { - return; - } + const size_t ack_size = this->extended_proto_ ? 2 : 1; #else - if (!this->try_write_(1, LOG_STR("ack feature"))) { + const size_t ack_size = 1; +#endif + if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } -#endif #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase From e55b7b54cb6f4cb0212f4c4f899604c9cb48bdba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:51:08 -0500 Subject: [PATCH 46/70] better handle failure case --- .../components/ota/ota_backend_esp_idf.cpp | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 52dc4756a8..91e3fe412a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -394,32 +394,55 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } void get_running_app_position(uint32_t &offset, size_t &size) { - // Gets the start address and the used length aligned to sectors of the running app. - // This function needs to be called once before calling esp_partition_unload_all(). - // The results are stored using static variables because esp_ota_get_running_partition() - // does not return valid data after calling esp_partition_unload_all(). - static uint32_t running_app_offset = 0; - static size_t running_app_size = 0; - if (running_app_size == 0) { + // Gets the start address and the used length (rounded up to flash sectors) of the running app. + // + // The result is cached because esp_ota_get_running_partition() does not return valid data after + // esp_partition_unload_all() has been called during a partition-table OTA. The running app does + // not move within a boot, so the first successful query is valid for the lifetime of the process. + // + // Caching is gated by an explicit `initialized` flag (rather than checking for size == 0) so a + // failed first call (e.g., esp_ota_get_running_partition() returning nullptr after a previously + // aborted partition-table OTA already called esp_partition_unload_all()) does not poison the + // cache; the next caller will retry. Values are written into the cache atomically only after the + // full computation succeeds. + static bool initialized = false; + static uint32_t cached_offset = 0; + static size_t cached_size = 0; + + if (!initialized) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - running_app_size = running_app_part->size; - running_app_offset = running_app_part->address; + if (running_app_part == nullptr || running_app_part->erase_size == 0) { + // Cannot determine the running app right now; surface zeros without committing to the cache + // so a later call has a chance to succeed. + offset = 0; + size = 0; + return; + } + + uint32_t pending_offset = running_app_part->address; + size_t pending_size = running_app_part->size; + const esp_partition_pos_t running_app_pos = { .offset = running_app_part->address, .size = running_app_part->size, }; - esp_image_metadata_t image_metadata; + esp_image_metadata_t image_metadata = {}; image_metadata.start_addr = running_app_part->address; - esp_err_t err = esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata); - if (err == ESP_OK && image_metadata.image_len < running_app_part->size) { - running_app_size = image_metadata.image_len; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && + image_metadata.image_len < running_app_part->size) { + pending_size = image_metadata.image_len; } - // Align running_app_size to flash sectors - running_app_size = ((running_app_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; + // Round up to flash sector size so the copy spans complete erase blocks. + pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + + cached_offset = pending_offset; + cached_size = pending_size; + initialized = true; } - offset = running_app_offset; - size = running_app_size; + + offset = cached_offset; + size = cached_size; } #endif From 185fa7e302c56760acdc6b0d9ae577d540156cba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:52:29 -0500 Subject: [PATCH 47/70] close dangling pointer risk --- esphome/components/ota/ota_backend_esp_idf.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 91e3fe412a..d44e2d0b27 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -372,9 +372,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - esp_partition_deregister_external(this->partition_table_part_); - this->partition_table_part_ = nullptr; + // esp_partition_unload_all() invalidates every cached partition entry, including the externally + // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the + // unload first, then null the member pointer so it never dangles past invalidation; if abort() + // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. esp_partition_unload_all(); + this->partition_table_part_ = nullptr; // Write otadata to set the new boot partition const esp_partition_info_t *new_part = From 6de08ea563e5e5b3517d3dd0a956dcef5cea30d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:57:18 -0500 Subject: [PATCH 48/70] more cleanups --- .../components/esphome/ota/ota_esphome.cpp | 11 +- esphome/components/esphome/ota/ota_esphome.h | 3 - esphome/components/ota/ota_backend.h | 4 +- .../components/ota/ota_backend_esp_idf.cpp | 7 +- esphome/espota2.py | 144 +++++++++--------- 5 files changed, 83 insertions(+), 86 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6897165f1d..ac4906780e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,9 +87,6 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif -#ifdef USE_OTA_PARTITIONS - ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); -#endif } void ESPHomeOTAComponent::dump_config() { @@ -104,12 +101,16 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS + // Avoid running esp_image_verify here: it reads and checksums the entire app image, which is too + // expensive for a config dump. The address comes from a cached lookup; the precise used size is + // computed lazily by update_partition_table() the first time a partition-table OTA is requested. + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" " Partition address: 0x%X\n" - " Used size: %zu bytes", - this->running_app_offset_, this->running_app_size_); + " Partition size: 0x%X bytes", + running_app_part->address, running_app_part->size); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 2da6e7dcd6..9bed9240aa 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -92,9 +92,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_PARTITIONS - // 4-byte members first, 1-byte member last to minimize padding. - uint32_t running_app_offset_{0}; - size_t running_app_size_{0}; bool extended_proto_{false}; #endif diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 41dbe5fcda..5888a8e12d 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,6 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include + #ifdef USE_OTA_STATE_LISTENER #include #endif @@ -53,7 +55,7 @@ enum OTAState { OTA_ERROR, }; -enum OTAType { +enum OTAType : uint8_t { OTA_TYPE_UPDATE_APP = 0x00, OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01, }; diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index d44e2d0b27..2f75b35a8a 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -321,7 +321,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - ESP_LOGW(TAG, "Checks passed, starting partition table update. Don't remove power until it is completed!"); + // Past this point any failure (power loss, watchdog reset, write error after the table has been + // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message + // is visible in default log filters. + ESP_LOGE(TAG, "Starting partition table update.\n" + " DO NOT REMOVE POWER until the device reboots successfully.\n" + " Loss of power during this operation may permanently brick the device."); // Deinitialize NVS to prevent unwanted flash writes nvs_flash_deinit(); diff --git a/esphome/espota2.py b/esphome/espota2.py index e8d27d69fa..9cab354514 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -73,6 +73,71 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +# Error response code -> human-readable message (without the "Error: " prefix; check_error() +# prepends it uniformly). Looked up by check_error() to translate a single byte from the device +# into an OTAError. Add new error codes here rather than extending the if-chain in check_error(). +_ERROR_MESSAGES: dict[int, str] = { + RESPONSE_ERROR_MAGIC: "Invalid magic byte", + RESPONSE_ERROR_UPDATE_PREPARE: ( + "Couldn't prepare flash memory for update. Is the binary too big? " + "Please try restarting the ESP." + ), + RESPONSE_ERROR_AUTH_INVALID: "Authentication invalid. Is the password correct?", + RESPONSE_ERROR_WRITING_FLASH: ( + "Writing OTA data to flash memory failed. See USB logs for more information." + ), + RESPONSE_ERROR_UPDATE_END: ( + "Finishing update failed. See the MQTT/USB logs for more information." + ), + RESPONSE_ERROR_INVALID_BOOTSTRAPPING: ( + "Please press the reset button on the ESP. A manual reset is " + "required on the first OTA-Update after flashing via USB." + ), + RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: ( + "ESP has been flashed with wrong flash size. Please choose the " + "correct 'board' option (esp01_1m always works) and then flash over USB." + ), + RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: ( + "ESP does not have the requested flash size (wrong board). Please " + "choose the correct 'board' option (esp01_1m always works) and try " + "uploading again." + ), + RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: ( + "ESP does not have enough space to store OTA file. Please try " + "flashing a minimal firmware (remove everything except ota)" + ), + RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( + "The OTA partition on the ESP is too small. ESPHome needs to resize " + "this partition, please flash over USB." + ), + RESPONSE_ERROR_NO_UPDATE_PARTITION: ( + "The OTA partition on the ESP couldn't be found. ESPHome needs to " + "create this partition, please flash over USB." + ), + RESPONSE_ERROR_MD5_MISMATCH: ( + "Application MD5 code mismatch. Please try again " + "or flash over USB with a good quality cable." + ), + RESPONSE_ERROR_SIGNATURE_INVALID: ( + "Firmware signature verification failed. The firmware was not signed " + "with the correct key. Ensure the signing key matches the one used to build " + "the firmware currently running on the device." + ), + RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: ( + "The requested OTA type is not supported by the device." + ), + RESPONSE_ERROR_PARTITION_TABLE_VERIFY: ( + "The partition table update could not be verified. No changes were " + "made to the flash content. Check the logs for more information and retry." + ), + RESPONSE_ERROR_PARTITION_TABLE_UPDATE: ( + "An error occurred while updating the partition table. The device may " + "not be able to reboot to a working application. Check the logs and retry " + "the update without rebooting the device." + ), + RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", +} + class OTAError(EsphomeError): pass @@ -148,82 +213,9 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "a network issue, or the connection was interrupted." ) dat = data[0] - if dat == RESPONSE_ERROR_MAGIC: - raise OTAError("Error: Invalid magic byte") - if dat == RESPONSE_ERROR_UPDATE_PREPARE: - raise OTAError( - "Error: Couldn't prepare flash memory for update. Is the binary too big? " - "Please try restarting the ESP." - ) - if dat == RESPONSE_ERROR_AUTH_INVALID: - raise OTAError("Error: Authentication invalid. Is the password correct?") - if dat == RESPONSE_ERROR_WRITING_FLASH: - raise OTAError( - "Error: Writing OTA data to flash memory failed. See USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_UPDATE_END: - raise OTAError( - "Error: Finishing update failed. See the MQTT/USB logs for more " - "information." - ) - if dat == RESPONSE_ERROR_INVALID_BOOTSTRAPPING: - raise OTAError( - "Error: Please press the reset button on the ESP. A manual reset is " - "required on the first OTA-Update after flashing via USB." - ) - if dat == RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: - raise OTAError( - "Error: ESP has been flashed with wrong flash size. Please choose the " - "correct 'board' option (esp01_1m always works) and then flash over USB." - ) - if dat == RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: - raise OTAError( - "Error: ESP does not have the requested flash size (wrong board). Please " - "choose the correct 'board' option (esp01_1m always works) and try " - "uploading again." - ) - if dat == RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: ESP does not have enough space to store OTA file. Please try " - "flashing a minimal firmware (remove everything except ota)" - ) - if dat == RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: - raise OTAError( - "Error: The OTA partition on the ESP is too small. ESPHome needs to resize " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_NO_UPDATE_PARTITION: - raise OTAError( - "Error: The OTA partition on the ESP couldn't be found. ESPHome needs to create " - "this partition, please flash over USB." - ) - if dat == RESPONSE_ERROR_MD5_MISMATCH: - raise OTAError( - "Error: Application MD5 code mismatch. Please try again " - "or flash over USB with a good quality cable." - ) - if dat == RESPONSE_ERROR_SIGNATURE_INVALID: - raise OTAError( - "Error: Firmware signature verification failed. The firmware was not signed " - "with the correct key. Ensure the signing key matches the one used to build " - "the firmware currently running on the device." - ) - if dat == RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: - raise OTAError("Error: The requested OTA type is not supported by the device.") - if dat == RESPONSE_ERROR_PARTITION_TABLE_VERIFY: - raise OTAError( - "Error: The partition table update could not be verified. No changes were " - "made to the flash content. Check the logs for more information and retry." - ) - if dat == RESPONSE_ERROR_PARTITION_TABLE_UPDATE: - raise OTAError( - "Error: An error occurred while updating the partition table. The device may not " - "be able to reboot to a working application. Check the logs and retry the update " - "without rebooting the device." - ) - if dat == RESPONSE_ERROR_UNKNOWN: - raise OTAError("Unknown error from ESP") + error_msg = _ERROR_MESSAGES.get(dat) + if error_msg is not None: + raise OTAError(f"Error: {error_msg}") if not isinstance(expect, (list, tuple)): expect = [expect] if dat not in expect: From bf1c0228a65a09cc3b1a299d0d778b650ba7603a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:22:53 -0500 Subject: [PATCH 49/70] copilot comments --- esphome/__main__.py | 9 +++++++ .../components/esphome/ota/ota_esphome.cpp | 8 +++++- .../components/ota/ota_backend_esp_idf.cpp | 25 ++++++++++++++----- esphome/core/__init__.py | 8 +++++- esphome/espota2.py | 8 +++++- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c931eb179d..deb4dbb63f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1124,6 +1124,15 @@ def upload_program( binary = CORE.firmware_bin ota_type = espota2.OTA_TYPE_UPDATE_APP if getattr(args, "partition_table", False): + # Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access. + # The device-side handshake also rejects this with "Device only supports app updates", + # but checking here surfaces the misconfiguration before opening a network connection. + if not ota_conf.get("allow_partition_access"): + raise EsphomeError( + "The option --partition-table requires 'allow_partition_access: true' on the " + "esphome OTA platform in the device's YAML configuration. Add it, recompile, " + "flash a build with the option enabled, and then retry --partition-table." + ) binary = CORE.partition_table_bin ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE if getattr(args, "file", None) is not None: diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ac4906780e..3ae7be7e8f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -104,13 +104,19 @@ void ESPHomeOTAComponent::dump_config() { // Avoid running esp_image_verify here: it reads and checksums the entire app image, which is too // expensive for a config dump. The address comes from a cached lookup; the precise used size is // computed lazily by update_partition_table() the first time a partition-table OTA is requested. + // Guard against esp_ota_get_running_partition() returning nullptr (can happen after the partition + // cache has been unloaded) so dump_config never crashes. + // Single ESP_LOGCONFIG call so the lines stay together as one log message; on the (rare) + // nullptr path we surface zeros rather than dereferencing. const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" " Partition address: 0x%X\n" " Partition size: 0x%X bytes", - running_app_part->address, running_app_part->size); + running_app_part != nullptr ? running_app_part->address : 0u, + running_app_part != nullptr ? running_app_part->size : 0u); + #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2f75b35a8a..fe28cb6288 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -25,8 +25,12 @@ std::unique_ptr make_ota_backend() { return make_uniqueota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - if (image_size > ESP_PARTITION_TABLE_MAX_LEN) { - ESP_LOGE(TAG, "Wrong partition table size"); + // Partition table images produced by gen_esp32part.py are padded with 0xFF and an MD5 entry to + // exactly ESP_PARTITION_TABLE_MAX_LEN bytes. Reject anything else: an undersized image would + // leave trailing bytes from the previous table in place after the partial write, and an + // oversized image cannot fit in the reserved region. This is stricter than verify alone. + if (image_size != ESP_PARTITION_TABLE_MAX_LEN) { + ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } memset(this->buf_, 0xFF, sizeof this->buf_); @@ -207,10 +211,17 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Get running app partition and used size + // Get running app partition and used size. A zero size means we couldn't determine the running + // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition + // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute + // overlap or copy bounds, so fail before any flash operation. uint32_t running_app_offset; size_t running_app_size; get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } // Get partition table partition esp_err_t err = esp_partition_register_external( @@ -328,9 +339,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { " DO NOT REMOVE POWER until the device reboots successfully.\n" " Loss of power during this operation may permanently brick the device."); - // Deinitialize NVS to prevent unwanted flash writes - nvs_flash_deinit(); - // Hold the watchdog open for the entire critical section: optional app copy, partition-table // erase/write, and boot partition selection. None of the steps below should yield long enough // to require a refresh, but bundling them under a single guard avoids spurious resets if the @@ -354,6 +362,11 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } + // Deinitialize NVS just before the first destructive write to the partition-table region. Doing + // this here (instead of earlier) means that any failure path in the verify or copy phases above + // returns with NVS still functional, so other components on the device aren't broken until reboot. + nvs_flash_deinit(); + // Update the partition table err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); if (err != ESP_OK) { diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index d97944fd2c..94a48dd31b 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -781,7 +781,13 @@ class EsphomeCore: @property def partition_table_bin(self) -> Path: - # native ESP-IDF: self.relative_build_path("build", "partition_table", "partition-table.bin") + # Native ESP-IDF (--native-idf): the partition table image is emitted under + # build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the + # equivalent file as partitions.bin in the env-specific .pioenvs directory. + if self.data.get(KEY_NATIVE_IDF): + return self.relative_build_path( + "build", "partition_table", "partition-table.bin" + ) return self.relative_pioenvs_path(self.name, "partitions.bin") @property diff --git a/esphome/espota2.py b/esphome/espota2.py index 9cab354514..891ed5b29c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -296,7 +296,13 @@ def perform_ota( else: features = 0 - if ota_type != 0 and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS: + if ota_type not in (OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE): + raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") + + if ( + ota_type == OTA_TYPE_UPDATE_PARTITION_TABLE + and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS + ): raise OTAError("Device only supports app updates") if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: From 8fd9af7f2ab67e5752fb505e0d92555e66e967e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 14:32:59 -0500 Subject: [PATCH 50/70] fix tests --- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fdfa0f5d5f..fec044a593 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1646,6 +1646,7 @@ def test_upload_program_ota_partition_table_with_file_arg( { CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, + "allow_partition_access": True, } ] } @@ -1685,6 +1686,35 @@ def test_upload_program_serial_partition_table( upload_program(config, args, devices) +def test_upload_program_ota_partition_table_without_allow_flag( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table must fail fast when allow_partition_access is not enabled in YAML.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="partitions.bin", partition_table=True) + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, + match="requires 'allow_partition_access: true'", + ): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + def test_upload_program_ota_no_config( mock_get_port_type: Mock, ) -> None: From 3b6c5a5aa5e68223a895f7d25f12dcb1c431cf81 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:01:30 +0200 Subject: [PATCH 51/70] Small changes --- esphome/components/ota/ota_backend_esp_idf.cpp | 1 - esphome/espota2.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 4f9c90f006..5843a010f3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -11,7 +11,6 @@ #include #ifdef USE_OTA_PARTITIONS -#include "esphome/components/watchdog/watchdog.h" #include #include #endif diff --git a/esphome/espota2.py b/esphome/espota2.py index 891ed5b29c..578ea92ce6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -300,7 +300,7 @@ def perform_ota( raise OTAError(f"Unsupported OTA type: 0x{ota_type:02X}") if ( - ota_type == OTA_TYPE_UPDATE_PARTITION_TABLE + ota_type != OTA_TYPE_UPDATE_APP and not features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS ): raise OTAError("Device only supports app updates") From cb97c35d3637c83d1319360f1119ae46c2c730f3 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 10:29:41 +0200 Subject: [PATCH 52/70] Backport changes --- .../components/esphome/ota/ota_esphome.cpp | 35 ++++++++----------- esphome/components/esphome/ota/ota_esphome.h | 2 -- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3ae7be7e8f..ca5a382875 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -237,19 +237,17 @@ void ESPHomeOTAComponent::handle_handshake_() { const bool supports_compression = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); - // Compose the feature-ack response. When USE_OTA_PARTITIONS is enabled and the client - // negotiates the extended protocol we emit a 2-byte response (marker + server feature flags); - // otherwise we emit the single-byte legacy response. The #ifdef wraps only the extended-proto - // branch so the legacy branch reads as unconditional code in either build configuration. -#ifdef USE_OTA_PARTITIONS + // Compose the feature-ack response. When the client negotiates the extended protocol we emit + // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte + // legacy response. this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; if (this->extended_proto_) { this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; - this->handshake_buf_[1] = - SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS | (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); - } else + this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); +#ifdef USE_OTA_PARTITIONS + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; #endif - { + } else { this->handshake_buf_[0] = supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK; } @@ -257,15 +255,12 @@ void ESPHomeOTAComponent::handle_handshake_() { } case OTAState::FEATURE_ACK: { -#ifdef USE_OTA_PARTITIONS - const size_t ack_size = this->extended_proto_ ? 2 : 1; -#else - const size_t ack_size = 1; -#endif + static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; + static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; + const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } - #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -349,9 +344,7 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; -#ifdef USE_OTA_PARTITIONS ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; -#endif #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif @@ -367,7 +360,6 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); -#ifdef USE_OTA_PARTITIONS if (this->extended_proto_) { // Read ota type, 1 byte if (!this->readall_(buf, 1)) { @@ -377,7 +369,6 @@ void ESPHomeOTAComponent::handle_data_() { ota_type = static_cast(buf[0]); } ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); -#endif // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { @@ -398,10 +389,14 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // This will block for a few seconds as it locks flash #ifdef USE_OTA_PARTITIONS error_code = this->backend_->begin(ota_size, ota_type); #else + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); #endif if (error_code != ota::OTA_RESPONSE_OK) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 9bed9240aa..f612451ab0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,9 +91,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD -#ifdef USE_OTA_PARTITIONS bool extended_proto_{false}; -#endif socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; From 2068d114eb0631a153e524514969150c8771b955 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:02:30 +0000 Subject: [PATCH 53/70] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index d56f9cb6a5..04e08f9009 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1077,4 +1077,4 @@ def test_check_error_passes_non_error_when_expect_is_none() -> None: """Non-error bytes with expect=None must pass through silently.""" espota2.check_error([espota2.RESPONSE_OK], None) espota2.check_error([espota2.RESPONSE_HEADER_OK], None) - espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) \ No newline at end of file + espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) From 2dd8a9913b5b639fc3e69ef2d49b909f0a2478be Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 18:15:40 +0200 Subject: [PATCH 54/70] Fix --- esphome/components/esphome/ota/ota_esphome.h | 1 - esphome/espota2.py | 2 +- tests/unit_tests/test_espota2.py | 24 -------------------- 3 files changed, 1 insertion(+), 26 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0431bd98e0..5043bc33ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -91,7 +91,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD - bool extended_proto_{false}; socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; diff --git a/esphome/espota2.py b/esphome/espota2.py index 85e24abcb7..abf56ed67a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -65,7 +65,7 @@ SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 # OTA types this client knows how to send. Future PRs that add bootloader/partition # updates extend this set. Anything outside the set is rejected up front so callers # of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. -_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP}) +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE}) UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 04e08f9009..28e30bbe77 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -892,30 +892,6 @@ def test_perform_ota_successful_partition_table( ) -@pytest.mark.usefixtures("mock_time") -def test_perform_ota_extended_protocol_unsupported( - mock_socket: Mock, mock_file: io.BytesIO -) -> None: - """Test OTA fails when extended protocol is required but unsupported.""" - # Setup socket responses for recv calls - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - ] - - mock_socket.recv.side_effect = recv_responses - - with pytest.raises(espota2.OTAError, match="Device only supports app updates"): - espota2.perform_ota( - mock_socket, - "testpass", - mock_file, - "partitions.bin", - espota2.OTA_TYPE_UPDATE_PARTITION_TABLE, - ) - - @pytest.mark.usefixtures("mock_time") def test_perform_ota_device_rejects_with_unsupported_ota_type( mock_socket: Mock, mock_file: io.BytesIO From 9951a837f1ffdab8d8471f0f851267be0e7d8d20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:17:31 +0000 Subject: [PATCH 55/70] [pre-commit.ci lite] apply automatic fixes --- esphome/espota2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index abf56ed67a..e50b748e98 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -65,7 +65,9 @@ SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 # OTA types this client knows how to send. Future PRs that add bootloader/partition # updates extend this set. Anything outside the set is rejected up front so callers # of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device. -_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE}) +_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( + {OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE} +) UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 From 63c9b63fcf19b36170158f44fcb3e3e496c4d510 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 1 May 2026 19:42:39 +0200 Subject: [PATCH 56/70] Apply suggestions --- esphome/components/esphome/ota/ota_esphome.cpp | 6 ++---- esphome/components/ota/ota_backend_esp_idf.h | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 4261b4c91e..84d6fa3c0e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -380,10 +380,12 @@ void ESPHomeOTAComponent::handle_data_() { (static_cast(buf[2]) << 8) | buf[3]; ESP_LOGV(TAG, "Size is %u bytes", ota_size); +#ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } +#endif // Now that we've passed authentication and are actually // starting the update, set the warning status and notify @@ -398,10 +400,6 @@ void ESPHomeOTAComponent::handle_data_() { #ifdef USE_OTA_PARTITIONS error_code = this->backend_->begin(ota_size, ota_type); #else - if (ota_type != ota::OTA_TYPE_UPDATE_APP) { - error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } // This will block for a few seconds as it locks flash error_code = this->backend_->begin(ota_size); #endif diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 08d4ac2abe..ed1f2496d1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -14,9 +14,7 @@ namespace esphome::ota { // ESP_PARTITION_TABLE_MAX_LEN (0xC00) so the entire partition table fits before verification. // Kept separate from any OTA chunk-transfer buffer to avoid coupling unrelated sizes. static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 -#endif -#ifdef USE_OTA_PARTITIONS void get_running_app_position(uint32_t &offset, size_t &size); #endif From 51c5500809abbf47ac718ff1b2213f79c8d53ca7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:15:28 -0500 Subject: [PATCH 57/70] [ota] Validate partition-table binary host-side before OTA Read the resolved partition-table file in upload_program before opening a network connection. Reject anything that isn't 0xC00 bytes, doesn't start with ESP_PARTITION_MAGIC, or is missing the MD5 checksum entry, so mistakes (wrong file, swapped --file path) surface as a local error instead of a post-handshake OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY. Includes unit tests covering size, magic, md5-presence, missing-file, and end-to-end upload_program rejection, plus three real partition tables checked in as fixtures (ESPHome build, ESP-IDF Hello-world, esphome_dashboard prebuilt). --- esphome/__main__.py | 56 +++++++++ .../partition_tables/esp_idf_hello_world.bin | Bin 0 -> 3072 bytes .../esphome_dashboard_firmware.bin | Bin 0 -> 3072 bytes .../partition_tables/esphome_default.bin | Bin 0 -> 3072 bytes tests/unit_tests/test_main.py | 110 +++++++++++++++++- 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin create mode 100644 tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin create mode 100644 tests/unit_tests/fixtures/partition_tables/esphome_default.bin diff --git a/esphome/__main__.py b/esphome/__main__.py index 9292e98043..c88efd6750 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1151,9 +1151,65 @@ def upload_program( if getattr(args, "file", None) is not None: binary = Path(args.file) + if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE: + _validate_partition_table_binary(binary) + return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) +# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a +# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as +# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the +# trailing checksum entry. Padding past the last entry is 0xFF. The full table is +# exactly ESP_PARTITION_TABLE_MAX_LEN bytes. +_PARTITION_TABLE_MAX_LEN = 0xC00 +_ESP_PARTITION_MAGIC = 0x50AA +_ESP_PARTITION_MAGIC_MD5 = 0xEBEB + + +def _validate_partition_table_binary(binary: Path) -> None: + """Validate that ``binary`` looks like an ESP32 partition table image. + + Catches common mistakes (wrong file, truncated build output, swapped --file path) + before opening a network connection so the failure mode is a clear local error + instead of a post-handshake device rejection. + """ + try: + data = binary.read_bytes() + except OSError as err: + raise EsphomeError( + f"Cannot read partition table file '{binary}': {err}" + ) from err + + if len(data) != _PARTITION_TABLE_MAX_LEN: + raise EsphomeError( + f"Partition table file '{binary}' has wrong size: expected " + f"{_PARTITION_TABLE_MAX_LEN} bytes, got {len(data)}. " + "Pass the partition table image (e.g. partitions.bin / partition-table.bin), " + "not the firmware image." + ) + + first_magic = data[0] | (data[1] << 8) + if first_magic != _ESP_PARTITION_MAGIC: + raise EsphomeError( + f"Partition table file '{binary}' does not start with the expected " + f"partition magic 0x{_ESP_PARTITION_MAGIC:04X} (got 0x{first_magic:04X}). " + "This file does not look like an ESP32 partition table." + ) + + # The MD5 checksum entry is required: without it the device-side + # esp_partition_table_verify will accept the table but the bootloader will + # refuse to boot from it. Scan the 32-byte entries for the MD5 magic. + if not any( + (data[off] | (data[off + 1] << 8)) == _ESP_PARTITION_MAGIC_MD5 + for off in range(0, _PARTITION_TABLE_MAX_LEN, 32) + ): + raise EsphomeError( + f"Partition table file '{binary}' is missing the MD5 checksum entry. " + "Regenerate the partition table with gen_esp32part.py or rebuild the project." + ) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) diff --git a/tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin b/tests/unit_tests/fixtures/partition_tables/esp_idf_hello_world.bin new file mode 100644 index 0000000000000000000000000000000000000000..b8fa03b4b3536b1f4d0def4c1fed550e8fc2acc7 GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fq@~fte62EtO{UcWca|qz#zcDP>@j>pP83gf~;m$0Eov3R*;sM zT#{c@2@-(g*RTJhfG=zPT`j`AV@pi8>6C7ps)8ap${7uT(GVC7fzc2c4S~@R7!85Z O5Eu=C(GZ|%2mk;_=9q5) literal 0 HcmV?d00001 diff --git a/tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin b/tests/unit_tests/fixtures/partition_tables/esphome_dashboard_firmware.bin new file mode 100644 index 0000000000000000000000000000000000000000..e648fa32709414410fbd9be592c84659b7ebb3ad GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fPo>ete62EtO{UcV0gg5z@WgukYAFRl30?6qGVM7g8%~qBLf42 zBtv3BfdPsn0|UdV00u#@W{A8Yraa?J1_nz8kSVFD1x5L}s47+kFg7s=STZntU|=XN z$V^K^bK>jQ|51SEf~h|;7*|G0zA!t#)$$w%isUF~Gz3ONU^E0qLtr!nMnhmU1V%$( KGz3O?2mk;I)@j>pP83gf+`P^VPs%n zkYPwHC@?^l1F=^HFbFa*$in0eL1M^wRRALs1A`?40|PrlURg1+6qx<`^?wv_Dy30A wx{o#F{@PPlP82`;j3PP884ZEa5Eu=C(GVC7fzc2c4S~@R7!85Z5WpJ(0C1>a2LJ#7 literal 0 HcmV?d00001 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 5f80a4217b..e5564b6933 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _get_configured_xtal_freq, _make_crystal_freq_callback, _resolve_network_devices, + _validate_partition_table_binary, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -1630,6 +1631,21 @@ def test_upload_program_ota_with_file_arg( ) +_PARTITION_TABLE_LEN = 0xC00 + + +def _make_partition_table_bytes() -> bytes: + """Build a minimal partition table image accepted by _validate_partition_table_binary.""" + table = bytearray(b"\xff" * _PARTITION_TABLE_LEN) + # First entry: ESP_PARTITION_MAGIC (0x50AA) little-endian -> bytes 0xAA, 0x50. + table[0] = 0xAA + table[1] = 0x50 + # MD5 checksum entry at offset 32: ESP_PARTITION_MAGIC_MD5 (0xEBEB) little-endian. + table[32] = 0xEB + table[33] = 0xEB + return bytes(table) + + def test_upload_program_ota_partition_table_with_file_arg( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -1641,6 +1657,9 @@ def test_upload_program_ota_partition_table_with_file_arg( mock_get_port_type.return_value = "NETWORK" mock_run_ota.return_value = (0, "192.168.1.100") + partition_file = tmp_path / "partitions.bin" + partition_file.write_bytes(_make_partition_table_bytes()) + config = { CONF_OTA: [ { @@ -1650,7 +1669,7 @@ def test_upload_program_ota_partition_table_with_file_arg( } ] } - args = MockArgs(file="partitions.bin", partition_table=True) + args = MockArgs(file=str(partition_file), partition_table=True) devices = ["192.168.1.100"] exit_code, host = upload_program(config, args, devices) @@ -1661,7 +1680,7 @@ def test_upload_program_ota_partition_table_with_file_arg( ["192.168.1.100"], 3232, None, - Path("partitions.bin"), + partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, ) @@ -1686,6 +1705,93 @@ def test_upload_program_serial_partition_table( upload_program(config, args, devices) +def test_validate_partition_table_binary_accepts_valid(tmp_path: Path) -> None: + f = tmp_path / "partitions.bin" + f.write_bytes(_make_partition_table_bytes()) + _validate_partition_table_binary(f) + + +_PARTITION_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "partition_tables" + + +@pytest.mark.parametrize( + "fixture", + [ + # Stock ESP-IDF gen_esp32part.py output for an ESPHome build. + "esphome_default.bin", + # ESP-IDF Hello-world example partition table (vendored from espressif/esp-serial-flasher). + "esp_idf_hello_world.bin", + # Partition table shipped with esphome_dashboard's prebuilt firmware. + "esphome_dashboard_firmware.bin", + ], +) +def test_validate_partition_table_binary_accepts_real_binaries(fixture: str) -> None: + """Real-world partition-table binaries from ESP-IDF / ESPHome tooling pass validation.""" + _validate_partition_table_binary(_PARTITION_FIXTURE_DIR / fixture) + + +def test_validate_partition_table_binary_rejects_wrong_size(tmp_path: Path) -> None: + f = tmp_path / "partitions.bin" + f.write_bytes(b"\xaa\x50" + b"\xff" * 100) + with pytest.raises(EsphomeError, match="wrong size"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_rejects_wrong_magic(tmp_path: Path) -> None: + data = bytearray(_make_partition_table_bytes()) + data[0] = 0x00 + data[1] = 0x00 + f = tmp_path / "partitions.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="partition magic"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_rejects_missing_md5(tmp_path: Path) -> None: + data = bytearray(_make_partition_table_bytes()) + data[32] = 0xFF + data[33] = 0xFF + f = tmp_path / "partitions.bin" + f.write_bytes(bytes(data)) + with pytest.raises(EsphomeError, match="missing the MD5 checksum entry"): + _validate_partition_table_binary(f) + + +def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Cannot read partition table file"): + _validate_partition_table_binary(tmp_path / "does-not-exist.bin") + + +def test_upload_program_ota_partition_table_invalid_file( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table must fail before calling run_ota when the file is not a partition table.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + + bad_file = tmp_path / "firmware.bin" + bad_file.write_bytes(b"\x00" * 4096) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(bad_file), partition_table=True) + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="wrong size"): + upload_program(config, args, devices) + mock_run_ota.assert_not_called() + + def test_upload_program_ota_partition_table_without_allow_flag( mock_run_ota: Mock, mock_get_port_type: Mock, From b788529ad7e5636b5d95d74e339cd8adc2ed7d65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:17:40 -0500 Subject: [PATCH 58/70] [ota] Polish partition-table review feedback Split the combined "missing app, otadata, or nvs" verify failure into three separate ESP_LOGE messages so users can see which check failed, trim trailing spaces from the (err=0x%X) log strings, and document why the partition-table espota2 test mocks SERVER_FEATURE_SUPPORTS_COMPRESSION (intentional protocol-path coverage; the real IDFOTABackend never sets it). --- .../components/ota/ota_backend_esp_idf.cpp | 30 ++++++++++++------- tests/unit_tests/test_espota2.py | 11 +++++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 5843a010f3..053887a7db 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -208,7 +208,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -218,13 +218,13 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, reinterpret_cast(&existing_partition_table), &partition_table_map); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); esp_partition_munmap(partition_table_map); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } @@ -233,7 +233,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } // Check for missing checksum @@ -303,8 +303,16 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - if (app_partitions_found < 2 || !otadata_partition_found || !nvs_partition_found) { - ESP_LOGE(TAG, "New partition table is missing the required app, otadata or nvs partitions"); + if (app_partitions_found < 2) { + ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!otadata_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { @@ -337,7 +345,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } } @@ -352,7 +360,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { if (err != ESP_OK) { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); @@ -361,13 +369,13 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // partial-write failure path self-contained. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } // esp_partition_unload_all() invalidates every cached partition entry, including the externally @@ -388,7 +396,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X) ", err); + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } return OTA_RESPONSE_OK; diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 28e30bbe77..2cad1d2ec8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -843,7 +843,14 @@ def test_perform_ota_extended_protocol_app( def test_perform_ota_successful_partition_table( mock_socket: Mock, mock_file: io.BytesIO ) -> None: - """Test OTA partition table update.""" + """Test OTA partition table update. + + The mocked server advertises both COMPRESSION and PARTITION_ACCESS to exercise + the full extended-protocol negotiation path. Real IDFOTABackend devices return + ``supports_compression() == false`` and never set the COMPRESSION flag for a + partition-table OTA; the flag here is intentional protocol-coverage, not a + description of on-device behaviour. + """ recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([espota2.OTA_VERSION_2_0]), # Version number @@ -853,7 +860,7 @@ def test_perform_ota_successful_partition_table( espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION | espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS ] - ), # Device feature flags + ), # Device feature flags (compression flag is unrealistic; see docstring) bytes([espota2.RESPONSE_AUTH_OK]), # No auth required bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK From 4490bbf23a87139cf13c07c977583a8ec12f1450 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:23:08 -0500 Subject: [PATCH 59/70] [ota] Extract partition-table validation into validate_new_partition_table_ Splits the non-destructive validation phase out of update_partition_table() into a dedicated method. update_partition_table() now reads top-down as "check buffer state, find running app, validate the new table + plan the target slot, then commit", with the destructive write isolated to the final block. The chosen slot and optional copy-source partition are returned via a small PartitionTablePlan struct so the caller no longer juggles the two candidate-index variables. Refactor only; behaviour and error semantics are unchanged. --- .../components/ota/ota_backend_esp_idf.cpp | 117 +++++++++++------- esphome/components/ota/ota_backend_esp_idf.h | 15 ++- 2 files changed, 83 insertions(+), 49 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 053887a7db..75560cbe0e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -184,26 +184,14 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } -OTAResponseTypes IDFOTABackend::update_partition_table() { - int num_partitions; - if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { - ESP_LOGE(TAG, "Not enough data received"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Get running app partition and used size. A zero size means we couldn't determine the running - // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition - // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute - // overlap or copy bounds, so fail before any flash operation. - uint32_t running_app_offset; - size_t running_app_size; - get_running_app_position(running_app_offset, running_app_size); - if (running_app_size == 0) { - ESP_LOGE(TAG, "Failed to determine running app position"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Get partition table partition +// Validate the new partition table image staged in ``buf_`` and pick the slot the running app +// will boot from after the update. Performs all non-destructive checks; the destructive write +// is in ``update_partition_table()``. Side-effect: registers the live partition-table region +// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. +OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan) { + // Register the live primary partition table as an external partition so we can mmap it for + // verification and later issue esp_ota_begin/esp_ota_write against it. esp_err_t err = esp_partition_register_external( nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); @@ -213,6 +201,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Verify existing partition table + int num_partitions = 0; const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, @@ -228,20 +217,22 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Verify new partition table + // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN + // bytes; ``buf_`` is sized to that exactly. const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - // esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN bytes of data err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Check for missing checksum - // esp_partition_table_verify does not fail in this case and the ESP would not boot after the update + + // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and + // the ESP would not boot after the update. bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { checksum_found = true; + break; } } if (!checksum_found) { @@ -249,56 +240,49 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Check if the required app and otadata partitions exist in the new partition table - // Check which app slot to boot from in the new partition table + // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, + // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the + // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already + // match) is preferred over the copy path; within each path we lock in the first match and stop + // searching. This keeps the choice deterministic and table-ordering-stable. int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; - const esp_partition_t *app_copy_target_part = nullptr; + const esp_partition_t *app_copy_source_part = nullptr; bool otadata_partition_found = false; bool otadata_overlap = false; bool nvs_partition_found = false; - // Selection policy when multiple app slots in the new partition table can host the running app: - // pick the FIRST eligible slot in table order. The no-copy path (offsets already match) is - // preferred over the copy path; within each path we lock in the first match and stop searching. - // This keeps the choice deterministic and table-ordering-stable instead of "last writer wins". - for (int i = 0; i < num_partitions; i++) { // Iterate over new partition table + for (int i = 0; i < num_partitions; i++) { const esp_partition_info_t *new_part = &new_partition_table[i]; if (new_part->type == ESP_PARTITION_TYPE_APP) { - // Found an app partition in the new partition table app_partitions_found++; if (new_part->pos.size >= running_app_size) { - // Running app can fit inside this partition if (new_part->pos.offset == running_app_offset) { - // This new app partition can be used for the running app without copying because the offsets are the same. - // First match wins; once locked in, the no-copy path is preferred and won't be overwritten. + // No-copy path: same offset as running app, first match wins. if (new_app_part_index == -1) { new_app_part_index = i; } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // This new app partition can be used for the running app after copying the app into it. - // Check if there is an app partition in the old partition table at the right offset - // (esp_partition_copy needs a registered source partition; first match wins for determinism). + // Copy path: needs a registered source partition in the *current* table at the new slot's offset. const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); if (p != nullptr) { - new_app_part_index_with_copy = i; // The partition index in the new partition table - app_copy_target_part = p; // The partition in the old partition table + new_app_part_index_with_copy = i; + app_copy_source_part = p; } } } } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - // Found the otadata partition in the new partition table otadata_partition_found = true; otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { - // Found the nvs partition in the new partition table nvs_partition_found = true; } } } + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -320,6 +304,41 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } + // No-copy preferred; copy path only when no-copy slot was unavailable. + if (new_app_part_index != -1) { + plan.target_app_index = new_app_part_index; + plan.copy_source_part = nullptr; + } else { + plan.target_app_index = new_app_part_index_with_copy; + plan.copy_source_part = app_copy_source_part; + } + return OTA_RESPONSE_OK; +} + +OTAResponseTypes IDFOTABackend::update_partition_table() { + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Get running app partition and used size. A zero size means we couldn't determine the running + // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition + // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute + // overlap or copy bounds, so fail before any flash operation. + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + PartitionTablePlan plan; + OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); + if (validate_result != OTA_RESPONSE_OK) { + return validate_result; + } + // Past this point any failure (power loss, watchdog reset, write error after the table has been // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message // is visible in default log filters. @@ -333,16 +352,19 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // underlying ESP-IDF calls take longer than expected on a given chip variant. watchdog::WatchdogManager watchdog(15000); + esp_err_t err; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + // Copy the running app partition to new position if needed. // esp_ota_get_running_partition() is still valid here (we have not yet called // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would // have located, without an extra iterator walk. - if (new_app_part_index == -1) { + if (plan.copy_source_part != nullptr) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, - app_copy_target_part->address, running_app_size); + plan.copy_source_part->address, running_app_size); - err = esp_partition_copy(app_copy_target_part, 0, running_app_part, 0, running_app_size); + err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); @@ -386,8 +408,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { this->partition_table_part_ = nullptr; // Write otadata to set the new boot partition - const esp_partition_info_t *new_part = - &new_partition_table[new_app_part_index == -1 ? new_app_part_index_with_copy : new_app_part_index]; + const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index ed1f2496d1..59712945f2 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -33,6 +33,17 @@ class IDFOTABackend final { protected: #ifdef USE_OTA_PARTITIONS + // Outcome of validating an incoming partition-table image. ``target_app_index`` is the + // entry in the new table that the running app will boot from after the update; + // ``copy_source_part`` is non-null when the running app must be copied into that slot + // first (the source is the matching slot in the *current* partition table). + struct PartitionTablePlan { + int target_app_index{-1}; + const esp_partition_t *copy_source_part{nullptr}; + }; + + OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan); OTAResponseTypes update_partition_table(); #endif @@ -45,7 +56,9 @@ class IDFOTABackend final { #ifdef USE_OTA_PARTITIONS // Place the byte buffer first so it sits immediately after the preceding `bool md5_set_`, // eliminating the 3-byte alignment padding that an int-sized member would otherwise force. - // Remaining members are 4-byte-aligned and pack tightly after the buffer. + // Remaining members are 4-byte-aligned and pack tightly after the buffer. The backend is + // constructed on each incoming OTA connection and destroyed on cleanup_connection_(), so this + // 3 KiB is only resident during an active OTA, not permanently. uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; From 36c120fb07923dc8d33619d384f2bc6acf3d2fb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:26:03 -0500 Subject: [PATCH 60/70] [ota] Hoist running-app cache to file scope The running-app position cache lived as three function-local statics inside get_running_app_position(). They cannot be IDFOTABackend members (the backend is per-connection, the cache must outlive a backend that called esp_partition_unload_all() in a prior aborted partition-table OTA), but burying them inside the function made the lifetime and shared-across-connections semantics implicit. Move them to file scope with s_running_app_ prefix so the process-scoped lifetime is visible at first read, and tighten the surrounding comments. No behaviour change. --- .../components/ota/ota_backend_esp_idf.cpp | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 75560cbe0e..a6b379a42f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -423,23 +423,22 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_OK; } -void get_running_app_position(uint32_t &offset, size_t &size) { - // Gets the start address and the used length (rounded up to flash sectors) of the running app. - // - // The result is cached because esp_ota_get_running_partition() does not return valid data after - // esp_partition_unload_all() has been called during a partition-table OTA. The running app does - // not move within a boot, so the first successful query is valid for the lifetime of the process. - // - // Caching is gated by an explicit `initialized` flag (rather than checking for size == 0) so a - // failed first call (e.g., esp_ota_get_running_partition() returning nullptr after a previously - // aborted partition-table OTA already called esp_partition_unload_all()) does not poison the - // cache; the next caller will retry. Values are written into the cache atomically only after the - // full computation succeeds. - static bool initialized = false; - static uint32_t cached_offset = 0; - static size_t cached_size = 0; +// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend +// because the backend is created/destroyed per OTA connection, while the cached values must +// survive across connections: once a previously aborted partition-table OTA has called +// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, +// so we have to remember the answer from the first successful call. The running app does not +// move within a boot, so a single capture is valid for the process lifetime. +static bool s_running_app_initialized = false; +static uint32_t s_running_app_cached_offset = 0; +static size_t s_running_app_cached_size = 0; - if (!initialized) { +void get_running_app_position(uint32_t &offset, size_t &size) { + // Returns the start address and the used length (rounded up to flash sectors) of the running app. + // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed + // first call does not poison it; the next caller retries. Values are written atomically only + // after the full computation succeeds. + if (!s_running_app_initialized) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); if (running_app_part == nullptr || running_app_part->erase_size == 0) { // Cannot determine the running app right now; surface zeros without committing to the cache @@ -466,13 +465,13 @@ void get_running_app_position(uint32_t &offset, size_t &size) { pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; - cached_offset = pending_offset; - cached_size = pending_size; - initialized = true; + s_running_app_cached_offset = pending_offset; + s_running_app_cached_size = pending_size; + s_running_app_initialized = true; } - offset = cached_offset; - size = cached_size; + offset = s_running_app_cached_offset; + size = s_running_app_cached_size; } #endif From b75f5034e5f5f0fcc0c9cb9b2039a1635b0ae7fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:29:05 -0500 Subject: [PATCH 61/70] [ota] Address remaining Copilot comments on partition-table OTA - upload_program: allow MQTT/MQTTIP devices for --partition-table. MQTTIP gets resolved to a real IP by _resolve_network_devices(), so rejecting any non-NETWORK port_type was incorrect; only SERIAL and BOOTSEL are non-OTA upload paths. - update_partition_table: re-initialize NVS on every failure path past nvs_flash_deinit() so components that survive a failed OTA aren't left with broken NVS handles. Success path stays as-is because the device reboots immediately afterwards. Adds an MQTTIP upload test and refreshes the gate's comment. --- esphome/__main__.py | 6 ++- .../components/ota/ota_backend_esp_idf.cpp | 8 ++++ tests/unit_tests/test_main.py | 41 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c88efd6750..9ab2dee189 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1091,7 +1091,11 @@ def upload_program( port_type = get_port_type(host) - if port_type != PortType.NETWORK and getattr(args, "partition_table", False): + # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by + # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. + if port_type in (PortType.SERIAL, PortType.BOOTSEL) and getattr( + args, "partition_table", False + ): raise EsphomeError( "The option --partition-table can only be used for Over The Air updates." ) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index a6b379a42f..f018429984 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -375,6 +375,9 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Deinitialize NVS just before the first destructive write to the partition-table region. Doing // this here (instead of earlier) means that any failure path in the verify or copy phases above // returns with NVS still functional, so other components on the device aren't broken until reboot. + // Each failure path past this point calls nvs_flash_init() before returning so that, if the + // device keeps running, components that depend on NVS aren't permanently broken. The success + // path skips reinit because the device reboots immediately afterwards. nvs_flash_deinit(); // Update the partition table @@ -383,6 +386,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); @@ -392,12 +396,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } // esp_partition_unload_all() invalidates every cached partition entry, including the externally @@ -412,12 +418,14 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); + nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } return OTA_RESPONSE_OK; diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e5564b6933..798a43a4ce 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1705,6 +1705,47 @@ def test_upload_program_serial_partition_table( upload_program(config, args, devices) +def test_upload_program_ota_partition_table_mqttip( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """--partition-table is allowed for MQTTIP devices; they resolve to a real IP at OTA time.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "MQTTIP" + mock_run_ota.return_value = (0, "192.168.1.100") + + partition_file = tmp_path / "partitions.bin" + partition_file.write_bytes(_make_partition_table_bytes()) + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + "allow_partition_access": True, + } + ] + } + args = MockArgs(file=str(partition_file), partition_table=True) + + with patch( + "esphome.__main__._resolve_network_devices", return_value=["192.168.1.100"] + ): + exit_code, host = upload_program(config, args, ["MQTTIP"]) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], + 3232, + None, + partition_file, + OTA_TYPE_UPDATE_PARTITION_TABLE, + ) + + def test_validate_partition_table_binary_accepts_valid(tmp_path: Path) -> None: f = tmp_path / "partitions.bin" f.write_bytes(_make_partition_table_bytes()) From 4dcccf2cdf6e86bacfd5382f8b9c87e7efb558eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:30:23 -0500 Subject: [PATCH 62/70] [ota] DRY NVS reinit on update_partition_table failure paths Replace the four explicit nvs_flash_init() calls with a small RAII guard (NvsReinitGuard) declared right after nvs_flash_deinit(). Each failure path now just returns; the guard reinits NVS in its destructor. The success path disarms it before the trailing return because the device reboots immediately afterwards and reinit would only churn the partition cache. No behaviour change. --- .../components/ota/ota_backend_esp_idf.cpp | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f018429984..88cb60675d 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -184,6 +184,23 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } +// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is +// called immediately before the first partition-table write so that earlier failure paths leave +// NVS functional; this guard re-initializes NVS on every early return past that point so any +// component still running after a failed OTA can keep using NVS. The success path disarms the +// guard before returning because the device reboots immediately afterwards and reinit would only +// churn the partition cache. +namespace { +struct NvsReinitGuard { + bool armed{true}; + ~NvsReinitGuard() { + if (armed) { + nvs_flash_init(); + } + } +}; +} // namespace + // Validate the new partition table image staged in ``buf_`` and pick the slot the running app // will boot from after the update. Performs all non-destructive checks; the destructive write // is in ``update_partition_table()``. Side-effect: registers the live partition-table region @@ -375,10 +392,10 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { // Deinitialize NVS just before the first destructive write to the partition-table region. Doing // this here (instead of earlier) means that any failure path in the verify or copy phases above // returns with NVS still functional, so other components on the device aren't broken until reboot. - // Each failure path past this point calls nvs_flash_init() before returning so that, if the - // device keeps running, components that depend on NVS aren't permanently broken. The success - // path skips reinit because the device reboots immediately afterwards. + // The RAII guard re-initializes NVS on every early-return below; the success path disarms it + // immediately before returning, since the device reboots right after. nvs_flash_deinit(); + NvsReinitGuard nvs_guard; // Update the partition table err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); @@ -386,7 +403,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); @@ -396,14 +412,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } // esp_partition_unload_all() invalidates every cached partition entry, including the externally @@ -418,16 +432,15 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); if (new_boot_partition == nullptr) { ESP_LOGE(TAG, "Selected app partition not found after partition table update"); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); err = esp_ota_set_boot_partition(new_boot_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); - nvs_flash_init(); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } + nvs_guard.armed = false; return OTA_RESPONSE_OK; } From d5cc5206dd84568034be47423caf540685991f43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:34:46 -0500 Subject: [PATCH 63/70] [ota] Move partition-table code into ota_partitions_esp_idf.cpp Splits the partition-table OTA implementation out of the shared ota_backend_esp_idf.cpp into a new translation unit gated by USE_OTA_PARTITIONS. Builds without allow_partition_access compile strictly less code and don't link esp_image_format / nvs_flash; the common app-OTA backend is also easier to read without ~340 lines of unrelated partition handling interleaved. What moves: validate_new_partition_table_, update_partition_table, get_running_app_position, the file-static running-app cache, the NvsReinitGuard RAII helper, and the find_app_partition_at / check_overlap helpers. What stays: begin/write/end/abort and the factory. Behaviour-preserving refactor. --- .../components/ota/ota_backend_esp_idf.cpp | 340 ----------------- .../components/ota/ota_partitions_esp_idf.cpp | 356 ++++++++++++++++++ 2 files changed, 356 insertions(+), 340 deletions(-) create mode 100644 esphome/components/ota/ota_partitions_esp_idf.cpp diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 88cb60675d..e84d8762a1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -10,11 +10,6 @@ #include #include -#ifdef USE_OTA_PARTITIONS -#include -#include -#endif - namespace esphome::ota { static const char *const TAG = "ota.idf"; @@ -161,340 +156,5 @@ void IDFOTABackend::abort() { this->update_handle_ = 0; } -#ifdef USE_OTA_PARTITIONS -static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { - return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); -} - -// Find the first registered APP partition whose address matches `address` and whose size is at least -// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so -// callers don't have to repeat (and correctly handle) the find/get/next/release dance. -static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { - const esp_partition_t *found = nullptr; - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - if (p->address == address && p->size >= min_size) { - found = p; - break; - } - it = esp_partition_next(it); - } - esp_partition_iterator_release(it); - return found; -} - -// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is -// called immediately before the first partition-table write so that earlier failure paths leave -// NVS functional; this guard re-initializes NVS on every early return past that point so any -// component still running after a failed OTA can keep using NVS. The success path disarms the -// guard before returning because the device reboots immediately afterwards and reinit would only -// churn the partition cache. -namespace { -struct NvsReinitGuard { - bool armed{true}; - ~NvsReinitGuard() { - if (armed) { - nvs_flash_init(); - } - } -}; -} // namespace - -// Validate the new partition table image staged in ``buf_`` and pick the slot the running app -// will boot from after the update. Performs all non-destructive checks; the destructive write -// is in ``update_partition_table()``. Side-effect: registers the live partition-table region -// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. -OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, - PartitionTablePlan &plan) { - // Register the live primary partition table as an external partition so we can mmap it for - // verification and later issue esp_ota_begin/esp_ota_write against it. - esp_err_t err = esp_partition_register_external( - nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", - ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Verify existing partition table - int num_partitions = 0; - const esp_partition_info_t *existing_partition_table = nullptr; - esp_partition_mmap_handle_t partition_table_map; - err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, - reinterpret_cast(&existing_partition_table), &partition_table_map); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); - esp_partition_munmap(partition_table_map); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN - // bytes; ``buf_`` is sized to that exactly. - const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - err = esp_partition_table_verify(new_partition_table, true, &num_partitions); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and - // the ESP would not boot after the update. - bool checksum_found = false; - for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { - if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { - checksum_found = true; - break; - } - } - if (!checksum_found) { - ESP_LOGE(TAG, "New partition table has no checksum"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, - // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the - // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already - // match) is preferred over the copy path; within each path we lock in the first match and stop - // searching. This keeps the choice deterministic and table-ordering-stable. - int app_partitions_found = 0; - int new_app_part_index = -1; - int new_app_part_index_with_copy = -1; - const esp_partition_t *app_copy_source_part = nullptr; - bool otadata_partition_found = false; - bool otadata_overlap = false; - bool nvs_partition_found = false; - for (int i = 0; i < num_partitions; i++) { - const esp_partition_info_t *new_part = &new_partition_table[i]; - if (new_part->type == ESP_PARTITION_TYPE_APP) { - app_partitions_found++; - if (new_part->pos.size >= running_app_size) { - if (new_part->pos.offset == running_app_offset) { - // No-copy path: same offset as running app, first match wins. - if (new_app_part_index == -1) { - new_app_part_index = i; - } - } else if (new_app_part_index_with_copy == -1 && - !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // Copy path: needs a registered source partition in the *current* table at the new slot's offset. - const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); - if (p != nullptr) { - new_app_part_index_with_copy = i; - app_copy_source_part = p; - } - } - } - } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { - if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { - otadata_partition_found = true; - otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); - } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && - strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { - nvs_partition_found = true; - } - } - } - - if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { - ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (app_partitions_found < 2) { - ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (!otadata_partition_found) { - ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (!nvs_partition_found) { - ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - if (otadata_overlap) { - ESP_LOGE(TAG, "New otadata partition overlaps with running app"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // No-copy preferred; copy path only when no-copy slot was unavailable. - if (new_app_part_index != -1) { - plan.target_app_index = new_app_part_index; - plan.copy_source_part = nullptr; - } else { - plan.target_app_index = new_app_part_index_with_copy; - plan.copy_source_part = app_copy_source_part; - } - return OTA_RESPONSE_OK; -} - -OTAResponseTypes IDFOTABackend::update_partition_table() { - if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { - ESP_LOGE(TAG, "Not enough data received"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - // Get running app partition and used size. A zero size means we couldn't determine the running - // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition - // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute - // overlap or copy bounds, so fail before any flash operation. - uint32_t running_app_offset; - size_t running_app_size; - get_running_app_position(running_app_offset, running_app_size); - if (running_app_size == 0) { - ESP_LOGE(TAG, "Failed to determine running app position"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; - } - - PartitionTablePlan plan; - OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); - if (validate_result != OTA_RESPONSE_OK) { - return validate_result; - } - - // Past this point any failure (power loss, watchdog reset, write error after the table has been - // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message - // is visible in default log filters. - ESP_LOGE(TAG, "Starting partition table update.\n" - " DO NOT REMOVE POWER until the device reboots successfully.\n" - " Loss of power during this operation may permanently brick the device."); - - // Hold the watchdog open for the entire critical section: optional app copy, partition-table - // erase/write, and boot partition selection. None of the steps below should yield long enough - // to require a refresh, but bundling them under a single guard avoids spurious resets if the - // underlying ESP-IDF calls take longer than expected on a given chip variant. - watchdog::WatchdogManager watchdog(15000); - - esp_err_t err; - const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - - // Copy the running app partition to new position if needed. - // esp_ota_get_running_partition() is still valid here (we have not yet called - // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would - // have located, without an extra iterator walk. - if (plan.copy_source_part != nullptr) { - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, - plan.copy_source_part->address, running_app_size); - - err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - } - - // Deinitialize NVS just before the first destructive write to the partition-table region. Doing - // this here (instead of earlier) means that any failure path in the verify or copy phases above - // returns with NVS still functional, so other components on the device aren't broken until reboot. - // The RAII guard re-initializes NVS on every early-return below; the success path disarms it - // immediately before returning, since the device reboots right after. - nvs_flash_deinit(); - NvsReinitGuard nvs_guard; - - // Update the partition table - err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); - if (err != ESP_OK) { - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); - if (err != ESP_OK) { - // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the - // partial-write failure path self-contained. - esp_ota_abort(this->update_handle_); - this->update_handle_ = 0; - ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - // esp_partition_unload_all() invalidates every cached partition entry, including the externally - // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the - // unload first, then null the member pointer so it never dangles past invalidation; if abort() - // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. - esp_partition_unload_all(); - this->partition_table_part_ = nullptr; - - // Write otadata to set the new boot partition - const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; - const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); - if (new_boot_partition == nullptr) { - ESP_LOGE(TAG, "Selected app partition not found after partition table update"); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); - err = esp_ota_set_boot_partition(new_boot_partition); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); - return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; - } - nvs_guard.armed = false; - return OTA_RESPONSE_OK; -} - -// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend -// because the backend is created/destroyed per OTA connection, while the cached values must -// survive across connections: once a previously aborted partition-table OTA has called -// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, -// so we have to remember the answer from the first successful call. The running app does not -// move within a boot, so a single capture is valid for the process lifetime. -static bool s_running_app_initialized = false; -static uint32_t s_running_app_cached_offset = 0; -static size_t s_running_app_cached_size = 0; - -void get_running_app_position(uint32_t &offset, size_t &size) { - // Returns the start address and the used length (rounded up to flash sectors) of the running app. - // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed - // first call does not poison it; the next caller retries. Values are written atomically only - // after the full computation succeeds. - if (!s_running_app_initialized) { - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); - if (running_app_part == nullptr || running_app_part->erase_size == 0) { - // Cannot determine the running app right now; surface zeros without committing to the cache - // so a later call has a chance to succeed. - offset = 0; - size = 0; - return; - } - - uint32_t pending_offset = running_app_part->address; - size_t pending_size = running_app_part->size; - - const esp_partition_pos_t running_app_pos = { - .offset = running_app_part->address, - .size = running_app_part->size, - }; - esp_image_metadata_t image_metadata = {}; - image_metadata.start_addr = running_app_part->address; - if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && - image_metadata.image_len < running_app_part->size) { - pending_size = image_metadata.image_len; - } - // Round up to flash sector size so the copy spans complete erase blocks. - pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * - running_app_part->erase_size; - - s_running_app_cached_offset = pending_offset; - s_running_app_cached_size = pending_size; - s_running_app_initialized = true; - } - - offset = s_running_app_cached_offset; - size = s_running_app_cached_size; -} -#endif - } // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp new file mode 100644 index 0000000000..f912916c73 --- /dev/null +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -0,0 +1,356 @@ +#ifdef USE_ESP32 +#include "ota_backend_esp_idf.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OTA_PARTITIONS +#include "esphome/components/watchdog/watchdog.h" +#include "esphome/core/log.h" + +#include +#include +#include + +#include + +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; + +static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { + return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); +} + +// Find the first registered APP partition whose address matches `address` and whose size is at least +// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so +// callers don't have to repeat (and correctly handle) the find/get/next/release dance. +static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { + const esp_partition_t *found = nullptr; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); + while (it != nullptr) { + const esp_partition_t *p = esp_partition_get(it); + if (p->address == address && p->size >= min_size) { + found = p; + break; + } + it = esp_partition_next(it); + } + esp_partition_iterator_release(it); + return found; +} + +// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is +// called immediately before the first partition-table write so that earlier failure paths leave +// NVS functional; this guard re-initializes NVS on every early return past that point so any +// component still running after a failed OTA can keep using NVS. The success path disarms the +// guard before returning because the device reboots immediately afterwards and reinit would only +// churn the partition cache. +namespace { +struct NvsReinitGuard { + bool armed{true}; + ~NvsReinitGuard() { + if (armed) { + nvs_flash_init(); + } + } +}; +} // namespace + +// Validate the new partition table image staged in ``buf_`` and pick the slot the running app +// will boot from after the update. Performs all non-destructive checks; the destructive write +// is in ``update_partition_table()``. Side-effect: registers the live partition-table region +// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. +OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, + PartitionTablePlan &plan) { + // Register the live primary partition table as an external partition so we can mmap it for + // verification and later issue esp_ota_begin/esp_ota_write against it. + esp_err_t err = esp_partition_register_external( + nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", + ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Verify existing partition table + int num_partitions = 0; + const esp_partition_info_t *existing_partition_table = nullptr; + esp_partition_mmap_handle_t partition_table_map; + err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA, + reinterpret_cast(&existing_partition_table), &partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + err = esp_partition_table_verify(existing_partition_table, true, &num_partitions); + esp_partition_munmap(partition_table_map); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN + // bytes; ``buf_`` is sized to that exactly. + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + err = esp_partition_table_verify(new_partition_table, true, &num_partitions); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and + // the ESP would not boot after the update. + bool checksum_found = false; + for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { + if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { + checksum_found = true; + break; + } + } + if (!checksum_found) { + ESP_LOGE(TAG, "New partition table has no checksum"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, + // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the + // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already + // match) is preferred over the copy path; within each path we lock in the first match and stop + // searching. This keeps the choice deterministic and table-ordering-stable. + int app_partitions_found = 0; + int new_app_part_index = -1; + int new_app_part_index_with_copy = -1; + const esp_partition_t *app_copy_source_part = nullptr; + bool otadata_partition_found = false; + bool otadata_overlap = false; + bool nvs_partition_found = false; + for (int i = 0; i < num_partitions; i++) { + const esp_partition_info_t *new_part = &new_partition_table[i]; + if (new_part->type == ESP_PARTITION_TYPE_APP) { + app_partitions_found++; + if (new_part->pos.size >= running_app_size) { + if (new_part->pos.offset == running_app_offset) { + // No-copy path: same offset as running app, first match wins. + if (new_app_part_index == -1) { + new_app_part_index = i; + } + } else if (new_app_part_index_with_copy == -1 && + !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { + // Copy path: needs a registered source partition in the *current* table at the new slot's offset. + const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); + if (p != nullptr) { + new_app_part_index_with_copy = i; + app_copy_source_part = p; + } + } + } + } else if (new_part->type == ESP_PARTITION_TYPE_DATA) { + if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) { + otadata_partition_found = true; + otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size); + } else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS && + strncmp(reinterpret_cast(new_part->label), "nvs", sizeof(new_part->label)) == 0) { + nvs_partition_found = true; + } + } + } + + if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { + ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (app_partitions_found < 2) { + ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!otadata_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required otadata partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (!nvs_partition_found) { + ESP_LOGE(TAG, "New partition table is missing the required nvs partition"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + if (otadata_overlap) { + ESP_LOGE(TAG, "New otadata partition overlaps with running app"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // No-copy preferred; copy path only when no-copy slot was unavailable. + if (new_app_part_index != -1) { + plan.target_app_index = new_app_part_index; + plan.copy_source_part = nullptr; + } else { + plan.target_app_index = new_app_part_index_with_copy; + plan.copy_source_part = app_copy_source_part; + } + return OTA_RESPONSE_OK; +} + +OTAResponseTypes IDFOTABackend::update_partition_table() { + if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) { + ESP_LOGE(TAG, "Not enough data received"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + // Get running app partition and used size. A zero size means we couldn't determine the running + // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition + // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute + // overlap or copy bounds, so fail before any flash operation. + uint32_t running_app_offset; + size_t running_app_size; + get_running_app_position(running_app_offset, running_app_size); + if (running_app_size == 0) { + ESP_LOGE(TAG, "Failed to determine running app position"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; + } + + PartitionTablePlan plan; + OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan); + if (validate_result != OTA_RESPONSE_OK) { + return validate_result; + } + + // Past this point any failure (power loss, watchdog reset, write error after the table has been + // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message + // is visible in default log filters. + ESP_LOGE(TAG, "Starting partition table update.\n" + " DO NOT REMOVE POWER until the device reboots successfully.\n" + " Loss of power during this operation may permanently brick the device."); + + // Hold the watchdog open for the entire critical section: optional app copy, partition-table + // erase/write, and boot partition selection. None of the steps below should yield long enough + // to require a refresh, but bundling them under a single guard avoids spurious resets if the + // underlying ESP-IDF calls take longer than expected on a given chip variant. + watchdog::WatchdogManager watchdog(15000); + + esp_err_t err; + const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); + + // Copy the running app partition to new position if needed. + // esp_ota_get_running_partition() is still valid here (we have not yet called + // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would + // have located, without an extra iterator walk. + if (plan.copy_source_part != nullptr) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, + plan.copy_source_part->address, running_app_size); + + err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + } + + // Deinitialize NVS just before the first destructive write to the partition-table region. Doing + // this here (instead of earlier) means that any failure path in the verify or copy phases above + // returns with NVS still functional, so other components on the device aren't broken until reboot. + // The RAII guard re-initializes NVS on every early-return below; the success path disarms it + // immediately before returning, since the device reboots right after. + nvs_flash_deinit(); + NvsReinitGuard nvs_guard; + + // Update the partition table + err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); + if (err != ESP_OK) { + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); + if (err != ESP_OK) { + // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the + // partial-write failure path self-contained. + esp_ota_abort(this->update_handle_); + this->update_handle_ = 0; + ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + err = esp_ota_end(this->update_handle_); + this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + // esp_partition_unload_all() invalidates every cached partition entry, including the externally + // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the + // unload first, then null the member pointer so it never dangles past invalidation; if abort() + // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. + esp_partition_unload_all(); + this->partition_table_part_ = nullptr; + + // Write otadata to set the new boot partition + const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index]; + const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0); + if (new_boot_partition == nullptr) { + ESP_LOGE(TAG, "Selected app partition not found after partition table update"); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address); + err = esp_ota_set_boot_partition(new_boot_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } + nvs_guard.armed = false; + return OTA_RESPONSE_OK; +} + +// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend +// because the backend is created/destroyed per OTA connection, while the cached values must +// survive across connections: once a previously aborted partition-table OTA has called +// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, +// so we have to remember the answer from the first successful call. The running app does not +// move within a boot, so a single capture is valid for the process lifetime. +static bool s_running_app_initialized = false; +static uint32_t s_running_app_cached_offset = 0; +static size_t s_running_app_cached_size = 0; + +void get_running_app_position(uint32_t &offset, size_t &size) { + // Returns the start address and the used length (rounded up to flash sectors) of the running app. + // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed + // first call does not poison it; the next caller retries. Values are written atomically only + // after the full computation succeeds. + if (!s_running_app_initialized) { + const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + if (running_app_part == nullptr || running_app_part->erase_size == 0) { + // Cannot determine the running app right now; surface zeros without committing to the cache + // so a later call has a chance to succeed. + offset = 0; + size = 0; + return; + } + + uint32_t pending_offset = running_app_part->address; + size_t pending_size = running_app_part->size; + + const esp_partition_pos_t running_app_pos = { + .offset = running_app_part->address, + .size = running_app_part->size, + }; + esp_image_metadata_t image_metadata = {}; + image_metadata.start_addr = running_app_part->address; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK && + image_metadata.image_len < running_app_part->size) { + pending_size = image_metadata.image_len; + } + // Round up to flash sector size so the copy spans complete erase blocks. + pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * + running_app_part->erase_size; + + s_running_app_cached_offset = pending_offset; + s_running_app_cached_size = pending_size; + s_running_app_initialized = true; + } + + offset = s_running_app_cached_offset; + size = s_running_app_cached_size; +} + +} // namespace esphome::ota + +#endif // USE_OTA_PARTITIONS +#endif // USE_ESP32 From c69b3c559099ad581dba3d8a34e57aabfbf4f486 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:43:55 -0500 Subject: [PATCH 64/70] [ota] Unify backend begin() signature and trim partition-table comments Every OTA backend's begin() now takes ``(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP)``. ESPHomeOTAComponent::handle_data_() calls ``backend_->begin(ota_size, ota_type)`` unconditionally; the USE_OTA_PARTITIONS ifdef around the call disappears. The four non-ESP32 backends (esp8266, rp2040, libretiny, host) accept the new parameter and reject anything other than OTA_TYPE_UPDATE_APP up front, so the client-visible behaviour is unchanged. Same commit also trims the verbose block comments in the partition- table TU and the IDF backend header, keeping the genuinely non-obvious WHYs (slot-selection policy, NvsReinitGuard semantics, unload-then-null ordering, cache-init flag, dump_config nullptr fallback) and dropping the procedural narration. No functional change. --- .../components/esphome/ota/ota_esphome.cpp | 15 +-- .../ota/ota_backend_arduino_libretiny.cpp | 5 +- .../ota/ota_backend_arduino_libretiny.h | 2 +- .../ota/ota_backend_arduino_rp2040.cpp | 5 +- .../ota/ota_backend_arduino_rp2040.h | 2 +- .../components/ota/ota_backend_esp8266.cpp | 5 +- esphome/components/ota/ota_backend_esp8266.h | 2 +- .../components/ota/ota_backend_esp_idf.cpp | 18 ++-- esphome/components/ota/ota_backend_esp_idf.h | 22 ++-- esphome/components/ota/ota_backend_host.cpp | 4 +- esphome/components/ota/ota_backend_host.h | 2 +- .../components/ota/ota_partitions_esp_idf.cpp | 102 ++++++------------ 12 files changed, 70 insertions(+), 114 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 84d6fa3c0e..00089c000d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -101,13 +101,8 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS - // Avoid running esp_image_verify here: it reads and checksums the entire app image, which is too - // expensive for a config dump. The address comes from a cached lookup; the precise used size is - // computed lazily by update_partition_table() the first time a partition-table OTA is requested. - // Guard against esp_ota_get_running_partition() returning nullptr (can happen after the partition - // cache has been unloaded) so dump_config never crashes. - // Single ESP_LOGCONFIG call so the lines stay together as one log message; on the (rare) - // nullptr path we surface zeros rather than dereferencing. + // running_app_part can be nullptr if the partition cache was unloaded by a prior aborted + // partition-table OTA; surface zeros instead of dereferencing. const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" @@ -397,12 +392,8 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif -#ifdef USE_OTA_PARTITIONS + // begin() may block for a few seconds while it locks flash. error_code = this->backend_->begin(ota_size, ota_type); -#else - // This will block for a few seconds as it locks flash - error_code = this->backend_->begin(ota_size); -#endif if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index dcd71e92dd..4cc99202a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -13,7 +13,10 @@ static const char *const TAG = "ota.arduino_libretiny"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) { +OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // 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) { diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 3d426e6759..c2716a44d1 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -8,7 +8,7 @@ namespace esphome::ota { class ArduinoLibreTinyOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index bc8ef812e6..0ca0602519 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -15,7 +15,10 @@ static const char *const TAG = "ota.arduino_rp2040"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) { +OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // 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); diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index 05bd2f5cc4..d04d5c1a84 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -10,7 +10,7 @@ namespace esphome::ota { class ArduinoRP2040OTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 7c9d392532..6a678fb419 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -50,7 +50,10 @@ static const char *const TAG = "ota.esp8266"; std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { +OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (ota_type != OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } // Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space if (image_size == 0) { // Round down to sector boundary: subtract one sector, then mask to sector alignment diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index b364e216a3..21b5c12c2d 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -14,7 +14,7 @@ namespace esphome::ota { /// by not having a global Update object in .bss. class ESP8266OTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index e84d8762a1..42d106bf1f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -16,14 +16,12 @@ static const char *const TAG = "ota.idf"; std::unique_ptr make_ota_backend() { return make_unique(); } -#ifdef USE_OTA_PARTITIONS OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { +#ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - // Partition table images produced by gen_esp32part.py are padded with 0xFF and an MD5 entry to - // exactly ESP_PARTITION_TABLE_MAX_LEN bytes. Reject anything else: an undersized image would - // leave trailing bytes from the previous table in place after the partial write, and an - // oversized image cannot fit in the reserved region. This is stricter than verify alone. + // Reject any size other than ESP_PARTITION_TABLE_MAX_LEN: under- leaves stale bytes from the + // previous table; over- can't fit the reserved region. if (image_size != ESP_PARTITION_TABLE_MAX_LEN) { ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; @@ -38,7 +36,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } #else -OTAResponseTypes IDFOTABackend::begin(size_t image_size) { + if (ota_type != ota::OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + } #endif #ifdef USE_OTA_ROLLBACK // If we're starting an OTA, the current boot is good enough - mark it valid @@ -148,10 +148,8 @@ void IDFOTABackend::abort() { this->partition_table_part_ = nullptr; } #endif - // Always tear down any open OTA handle. update_partition_table() opens a handle internally to - // write the new partition table; if esp_ota_write/esp_ota_end fail mid-flight, the handle must - // be released here so it isn't leaked. esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG - // harmlessly, so the unconditional call is safe whether or not we're mid-update. + // esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG harmlessly, so this is safe whether + // or not an update is in flight. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; } diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 59712945f2..5e45b6d016 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -10,9 +10,7 @@ namespace esphome::ota { #ifdef USE_OTA_PARTITIONS -// Dedicated staging buffer size for the new partition table image. Must be at least -// ESP_PARTITION_TABLE_MAX_LEN (0xC00) so the entire partition table fits before verification. -// Kept separate from any OTA chunk-transfer buffer to avoid coupling unrelated sizes. +// Staging buffer holds the entire partition table for verification before any flash op. static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00 void get_running_app_position(uint32_t &offset, size_t &size); @@ -20,11 +18,7 @@ void get_running_app_position(uint32_t &offset, size_t &size); class IDFOTABackend final { public: -#ifdef USE_OTA_PARTITIONS OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP); -#else - OTAResponseTypes begin(size_t image_size); -#endif void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); @@ -33,10 +27,8 @@ class IDFOTABackend final { protected: #ifdef USE_OTA_PARTITIONS - // Outcome of validating an incoming partition-table image. ``target_app_index`` is the - // entry in the new table that the running app will boot from after the update; - // ``copy_source_part`` is non-null when the running app must be copied into that slot - // first (the source is the matching slot in the *current* partition table). + // copy_source_part non-null means the running app must be copied from this slot in the current + // table into target_app_index in the new table before the table is committed. struct PartitionTablePlan { int target_app_index{-1}; const esp_partition_t *copy_source_part{nullptr}; @@ -54,11 +46,9 @@ class IDFOTABackend final { char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS - // Place the byte buffer first so it sits immediately after the preceding `bool md5_set_`, - // eliminating the 3-byte alignment padding that an int-sized member would otherwise force. - // Remaining members are 4-byte-aligned and pack tightly after the buffer. The backend is - // constructed on each incoming OTA connection and destroyed on cleanup_connection_(), so this - // 3 KiB is only resident during an active OTA, not permanently. + // Buffer first so it packs tightly after the preceding `bool md5_set_` with no alignment + // padding. Only resident during an active OTA: the backend is constructed per connection and + // destroyed on cleanup_connection_(). uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE]; size_t buf_written_{0}; size_t image_size_{0}; diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 2e2132418d..a2c9f2cc33 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -10,7 +10,9 @@ namespace esphome::ota { std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes HostOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UPDATE_PREPARE; } +OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; +} void HostOTABackend::set_update_md5(const char *expected_md5) {} diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 300facf72f..4451fdfe18 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -9,7 +9,7 @@ namespace esphome::ota { /// OTA triggers to compile for host platform during development. class HostOTABackend final { public: - OTAResponseTypes begin(size_t image_size); + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f912916c73..910c63e8d7 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -21,9 +21,8 @@ static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_of return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); } -// Find the first registered APP partition whose address matches `address` and whose size is at least -// `min_size`. Returns nullptr when no match exists. Encapsulates the iterator + release pattern so -// callers don't have to repeat (and correctly handle) the find/get/next/release dance. +// Wraps esp_partition_find/_get/_next/_release. Returns nullptr if no APP partition at `address` +// is at least `min_size` bytes. static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) { const esp_partition_t *found = nullptr; esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr); @@ -39,12 +38,8 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } -// RAII helper for the destructive section of update_partition_table(). nvs_flash_deinit() is -// called immediately before the first partition-table write so that earlier failure paths leave -// NVS functional; this guard re-initializes NVS on every early return past that point so any -// component still running after a failed OTA can keep using NVS. The success path disarms the -// guard before returning because the device reboots immediately afterwards and reinit would only -// churn the partition cache. +// Re-inits NVS unless disarmed. Used so failure paths past nvs_flash_deinit() leave NVS usable +// for any component still running after a failed OTA. namespace { struct NvsReinitGuard { bool armed{true}; @@ -56,14 +51,12 @@ struct NvsReinitGuard { }; } // namespace -// Validate the new partition table image staged in ``buf_`` and pick the slot the running app -// will boot from after the update. Performs all non-destructive checks; the destructive write -// is in ``update_partition_table()``. Side-effect: registers the live partition-table region -// as ``partition_table_part_`` so the caller can write to it; ``abort()`` releases it on error. +// Validates the staged partition table and picks the post-update boot slot. All non-destructive +// checks live here; the destructive write is in update_partition_table(). +// Side effect: registers the live partition-table region as partition_table_part_ so the caller +// can write to it; abort() releases it on error. OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, PartitionTablePlan &plan) { - // Register the live primary partition table as an external partition so we can mmap it for - // verification and later issue esp_ota_begin/esp_ota_write against it. esp_err_t err = esp_partition_register_external( nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_); @@ -72,7 +65,6 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Verify existing partition table int num_partitions = 0; const esp_partition_info_t *existing_partition_table = nullptr; esp_partition_mmap_handle_t partition_table_map; @@ -89,8 +81,6 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Verify new partition table. esp_partition_table_verify expects ESP_PARTITION_TABLE_MAX_LEN - // bytes; ``buf_`` is sized to that exactly. const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); err = esp_partition_table_verify(new_partition_table, true, &num_partitions); if (err != ESP_OK) { @@ -98,8 +88,8 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Check for missing checksum entry. esp_partition_table_verify does not fail in this case and - // the ESP would not boot after the update. + // esp_partition_table_verify does not catch a missing MD5 entry, but the bootloader refuses + // to boot from a table without one. bool checksum_found = false; for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) { if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) { @@ -112,11 +102,9 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Walk the new table once, populating: the chosen target app slot, presence of otadata/nvs, - // and otadata-vs-running-app overlap. Selection policy when multiple app slots can host the - // running app: pick the FIRST eligible slot in table order. The no-copy path (offsets already - // match) is preferred over the copy path; within each path we lock in the first match and stop - // searching. This keeps the choice deterministic and table-ordering-stable. + // Slot-selection policy when multiple slots can host the running app: pick the FIRST eligible + // slot in table order, preferring the no-copy path (matching offset) over the copy path. + // Deterministic and table-ordering-stable. int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; @@ -130,13 +118,12 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a app_partitions_found++; if (new_part->pos.size >= running_app_size) { if (new_part->pos.offset == running_app_offset) { - // No-copy path: same offset as running app, first match wins. if (new_app_part_index == -1) { new_app_part_index = i; } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // Copy path: needs a registered source partition in the *current* table at the new slot's offset. + // esp_partition_copy needs a registered source partition in the current table. const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); if (p != nullptr) { new_app_part_index_with_copy = i; @@ -176,7 +163,6 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // No-copy preferred; copy path only when no-copy slot was unavailable. if (new_app_part_index != -1) { plan.target_app_index = new_app_part_index; plan.copy_source_part = nullptr; @@ -193,10 +179,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } - // Get running app partition and used size. A zero size means we couldn't determine the running - // app (e.g., esp_ota_get_running_partition() returned nullptr after a previous aborted partition - // table OTA called esp_partition_unload_all()). Without a valid size we cannot safely compute - // overlap or copy bounds, so fail before any flash operation. + // Without a valid running-app size we cannot compute overlap or copy bounds. zero indicates + // esp_ota_get_running_partition() failed (e.g. cache unloaded by a previous aborted OTA). uint32_t running_app_offset; size_t running_app_size; get_running_app_position(running_app_offset, running_app_size); @@ -211,26 +195,21 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return validate_result; } - // Past this point any failure (power loss, watchdog reset, write error after the table has been - // partially erased) can leave the device unable to boot. Logged at ERROR severity so the message - // is visible in default log filters. + // ERROR severity so the warning shows up in default log filters; any failure past this point + // can leave the device unable to boot. ESP_LOGE(TAG, "Starting partition table update.\n" " DO NOT REMOVE POWER until the device reboots successfully.\n" " Loss of power during this operation may permanently brick the device."); - // Hold the watchdog open for the entire critical section: optional app copy, partition-table - // erase/write, and boot partition selection. None of the steps below should yield long enough - // to require a refresh, but bundling them under a single guard avoids spurious resets if the - // underlying ESP-IDF calls take longer than expected on a given chip variant. + // One guard over the whole critical section in case an IDF call takes longer than expected on + // some chip variant. watchdog::WatchdogManager watchdog(15000); esp_err_t err; const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - // Copy the running app partition to new position if needed. - // esp_ota_get_running_partition() is still valid here (we have not yet called - // esp_partition_unload_all()) and returns the same partition that find_app_partition_at would - // have located, without an extra iterator walk. + // esp_ota_get_running_partition() is still valid here (esp_partition_unload_all() has not run) + // so use it directly instead of repeating the iterator walk. if (plan.copy_source_part != nullptr) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, @@ -244,11 +223,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } } - // Deinitialize NVS just before the first destructive write to the partition-table region. Doing - // this here (instead of earlier) means that any failure path in the verify or copy phases above - // returns with NVS still functional, so other components on the device aren't broken until reboot. - // The RAII guard re-initializes NVS on every early-return below; the success path disarms it - // immediately before returning, since the device reboots right after. + // Deinit NVS only just before the first destructive write so verify/copy failure paths return + // with NVS still functional. The guard re-inits on early returns; success disarms it. nvs_flash_deinit(); NvsReinitGuard nvs_guard; @@ -262,23 +238,20 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN); if (err != ESP_OK) { - // Release the handle eagerly; abort() would also do this, but cleaning up locally keeps the - // partial-write failure path self-contained. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } err = esp_ota_end(this->update_handle_); - this->update_handle_ = 0; // esp_ota_end releases the handle internally regardless of result + this->update_handle_ = 0; // esp_ota_end releases the handle internally if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - // esp_partition_unload_all() invalidates every cached partition entry, including the externally - // registered `partition_table_part_`, so the explicit deregister call is redundant. Do the - // unload first, then null the member pointer so it never dangles past invalidation; if abort() - // were ever to observe an in-between state, it would see a non-null but freed pointer and crash. + // unload first, then null the member pointer; if abort() ran between the two steps it would + // see a freed pointer. esp_partition_unload_all() invalidates partition_table_part_ too, so + // an explicit deregister would be redundant. esp_partition_unload_all(); this->partition_table_part_ = nullptr; @@ -299,26 +272,19 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { return OTA_RESPONSE_OK; } -// Process-scoped cache of the running app's flash position. Cannot live on IDFOTABackend -// because the backend is created/destroyed per OTA connection, while the cached values must -// survive across connections: once a previously aborted partition-table OTA has called -// esp_partition_unload_all(), esp_ota_get_running_partition() no longer returns valid data, -// so we have to remember the answer from the first successful call. The running app does not -// move within a boot, so a single capture is valid for the process lifetime. +// Process-scoped cache. Cannot be a backend member: backends are per-connection but the cache +// must outlive a connection that called esp_partition_unload_all(), after which +// esp_ota_get_running_partition() no longer returns valid data. static bool s_running_app_initialized = false; static uint32_t s_running_app_cached_offset = 0; static size_t s_running_app_cached_size = 0; +// Flag-gated rather than size==0 so a failed first call doesn't poison the cache. void get_running_app_position(uint32_t &offset, size_t &size) { - // Returns the start address and the used length (rounded up to flash sectors) of the running app. - // The ``s_running_app_initialized`` flag (rather than ``size == 0``) gates the cache so a failed - // first call does not poison it; the next caller retries. Values are written atomically only - // after the full computation succeeds. if (!s_running_app_initialized) { const esp_partition_t *running_app_part = esp_ota_get_running_partition(); if (running_app_part == nullptr || running_app_part->erase_size == 0) { - // Cannot determine the running app right now; surface zeros without committing to the cache - // so a later call has a chance to succeed. + // Surface zeros without committing to the cache so a later call has a chance to succeed. offset = 0; size = 0; return; @@ -337,7 +303,7 @@ void get_running_app_position(uint32_t &offset, size_t &size) { image_metadata.image_len < running_app_part->size) { pending_size = image_metadata.image_len; } - // Round up to flash sector size so the copy spans complete erase blocks. + // Round up to a full flash sector so the copy spans complete erase blocks. pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) * running_app_part->erase_size; From 87f152965dfcc2db1deb185b941b02e3c47e2a9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 09:48:06 -0500 Subject: [PATCH 65/70] [ota] Rename copy_source_part -> copy_dest_part and harden running-app lookup The PartitionTablePlan field that drives the optional app copy stores the partition that will receive the running app (i.e. the destination in the current table at the new slot's flash offset). The old name ``copy_source_part`` described the value backwards and risked future callers swapping the esp_partition_copy() arguments; renamed to ``copy_dest_part`` along with the matching local ``app_copy_dest_part`` and the comments around them. Also replace esp_ota_get_running_partition() in the copy path with find_app_partition_at(running_app_offset, running_app_size). The IDF call can return nullptr after a prior aborted partition-table OTA in the same boot called esp_partition_unload_all() (the same condition the cache in get_running_app_position() exists for); the previous code would have dereferenced nullptr on the retry that the client error message explicitly suggests. No functional change on the success path; nullptr deref on the failed- retry path is now reported as OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE. --- esphome/components/ota/ota_backend_esp_idf.h | 8 +++-- .../components/ota/ota_partitions_esp_idf.cpp | 30 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 5e45b6d016..54fdd24f93 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -27,11 +27,13 @@ class IDFOTABackend final { protected: #ifdef USE_OTA_PARTITIONS - // copy_source_part non-null means the running app must be copied from this slot in the current - // table into target_app_index in the new table before the table is committed. + // 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 + // because that's where esp_partition_copy can write; once the new table replaces it, the same + // flash region becomes target_app_index in the new table. struct PartitionTablePlan { int target_app_index{-1}; - const esp_partition_t *copy_source_part{nullptr}; + const esp_partition_t *copy_dest_part{nullptr}; }; OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size, diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index 910c63e8d7..f09a0fd145 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -108,7 +108,7 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a int app_partitions_found = 0; int new_app_part_index = -1; int new_app_part_index_with_copy = -1; - const esp_partition_t *app_copy_source_part = nullptr; + const esp_partition_t *app_copy_dest_part = nullptr; bool otadata_partition_found = false; bool otadata_overlap = false; bool nvs_partition_found = false; @@ -123,11 +123,12 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a } } else if (new_app_part_index_with_copy == -1 && !check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) { - // esp_partition_copy needs a registered source partition in the current table. + // esp_partition_copy writes into a registered partition; need one at this offset in the + // current table. const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size); if (p != nullptr) { new_app_part_index_with_copy = i; - app_copy_source_part = p; + app_copy_dest_part = p; } } } @@ -165,10 +166,10 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a if (new_app_part_index != -1) { plan.target_app_index = new_app_part_index; - plan.copy_source_part = nullptr; + plan.copy_dest_part = nullptr; } else { plan.target_app_index = new_app_part_index_with_copy; - plan.copy_source_part = app_copy_source_part; + plan.copy_dest_part = app_copy_dest_part; } return OTA_RESPONSE_OK; } @@ -208,15 +209,18 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { esp_err_t err; const esp_partition_info_t *new_partition_table = reinterpret_cast(this->buf_); - // esp_ota_get_running_partition() is still valid here (esp_partition_unload_all() has not run) - // so use it directly instead of repeating the iterator walk. - if (plan.copy_source_part != nullptr) { - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); + if (plan.copy_dest_part != nullptr) { + // Resolve the source via running_app_offset rather than esp_ota_get_running_partition() in + // case a prior aborted partition-table OTA called esp_partition_unload_all() in this boot, + // which leaves esp_ota_get_running_partition() returning nullptr. + const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size); + if (running_app_part == nullptr) { + ESP_LOGE(TAG, "Cannot resolve running app partition at offset 0x%X", running_app_offset); + return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; + } ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address, - plan.copy_source_part->address, running_app_size); - - err = esp_partition_copy(plan.copy_source_part, 0, running_app_part, 0, running_app_size); - + plan.copy_dest_part->address, running_app_size); + err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; From 1895ef1c3e667910d3eb86053ddbd7c8dcdaad98 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 3 May 2026 20:03:38 +0200 Subject: [PATCH 66/70] Restore 'Used size' of running app in dump_config --- esphome/components/esphome/ota/ota_esphome.cpp | 12 ++++++------ esphome/components/esphome/ota/ota_esphome.h | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 00089c000d..369f39e22b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,6 +87,10 @@ void ESPHomeOTAComponent::setup() { // no wakes fire and loop() falls back to the self-disable safety net. esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif + +#ifdef USE_OTA_PARTITIONS + ota::get_running_app_position(this->running_app_offset_, this->running_app_size_); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -101,16 +105,12 @@ void ESPHomeOTAComponent::dump_config() { } #endif #ifdef USE_OTA_PARTITIONS - // running_app_part can be nullptr if the partition cache was unloaded by a prior aborted - // partition-table OTA; surface zeros instead of dereferencing. - const esp_partition_t *running_app_part = esp_ota_get_running_partition(); ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" " Partition address: 0x%X\n" - " Partition size: 0x%X bytes", - running_app_part != nullptr ? running_app_part->address : 0u, - running_app_part != nullptr ? running_app_part->size : 0u); + " Used size: %zu bytes (0x%X)", + this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 ESP_LOGCONFIG(TAG, " Partition table:"); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5043bc33ef..0053ca6969 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -98,6 +98,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint32_t client_connect_time_{0}; static constexpr size_t HANDSHAKE_BUF_SIZE = 5; +#ifdef USE_OTA_PARTITIONS + uint32_t running_app_offset_{0}; + size_t running_app_size_{0}; +#endif uint16_t port_; uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE]; OTAState ota_state_{OTAState::IDLE}; From c19d06a07fd3e032df2b3e7af2c6778dafbc8357 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 3 May 2026 20:47:49 +0200 Subject: [PATCH 67/70] Format partition table similar to debug component --- esphome/components/esphome/ota/ota_esphome.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 369f39e22b..e6e75bd6e0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -113,12 +113,15 @@ void ESPHomeOTAComponent::dump_config() { this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 - ESP_LOGCONFIG(TAG, " Partition table:"); - esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr); - while (it != nullptr) { - const esp_partition_t *p = esp_partition_get(it); - ESP_LOGCONFIG(TAG, " %s: type=0x%X, subtype=0x%X, address=0x%X, size=0x%X", p->label, p->type, p->subtype, - p->address, p->size); + ESP_LOGCONFIG(TAG, + " Partition table:\n" + " %-12s %-4s %-8s %-10s %-10s", + "Name", "Type", "Subtype", "Address", "Size"); + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *partition = esp_partition_get(it); + ESP_LOGCONFIG(TAG, " %-12s 0x%-2X 0x%-6X 0x%-8" PRIX32 " 0x%-8" PRIX32, partition->label, partition->type, + partition->subtype, partition->address, partition->size); it = esp_partition_next(it); } esp_partition_iterator_release(it); From d30f82784c72d5efb08ac82d8734c8caa1d18638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 14:24:44 -0500 Subject: [PATCH 68/70] [ota] Drop ineffective NvsReinitGuard; soften brick wording Mat931 verified empirically that ``nvs_flash_init()`` after ``nvs_flash_deinit()`` does not revive NVS handles already held by other components: writes still fail with ESP_ERR_NVS_INVALID_HANDLE. The guard therefore only created the appearance of a recovery path. Remove it and document the actual contract: from nvs_flash_deinit() onward, components that hold open NVS handles will fail until the device is rebooted. The success path reboots immediately, so it is unaffected; the failure path now tells the user clearly to reboot and retry, rather than implying retry-without-reboot will work. Also soften the "permanently brick" wording in the pre-write log warning -- a bad partition-table OTA leaves a device that needs a serial flash to recover, not a permanently-dead one. --- .../components/ota/ota_partitions_esp_idf.cpp | 26 ++++++------------- esphome/espota2.py | 7 ++--- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index f09a0fd145..3a79136c61 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -38,19 +38,6 @@ static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min return found; } -// Re-inits NVS unless disarmed. Used so failure paths past nvs_flash_deinit() leave NVS usable -// for any component still running after a failed OTA. -namespace { -struct NvsReinitGuard { - bool armed{true}; - ~NvsReinitGuard() { - if (armed) { - nvs_flash_init(); - } - } -}; -} // namespace - // Validates the staged partition table and picks the post-update boot slot. All non-destructive // checks live here; the destructive write is in update_partition_table(). // Side effect: registers the live partition-table region as partition_table_part_ so the caller @@ -197,10 +184,11 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // ERROR severity so the warning shows up in default log filters; any failure past this point - // can leave the device unable to boot. + // can leave the device unbootable until it is recovered with a serial flash. ESP_LOGE(TAG, "Starting partition table update.\n" " DO NOT REMOVE POWER until the device reboots successfully.\n" - " Loss of power during this operation may permanently brick the device."); + " Loss of power during this operation may render the device unable to boot until\n" + " it is recovered via a serial flash."); // One guard over the whole critical section in case an IDF call takes longer than expected on // some chip variant. @@ -228,9 +216,12 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { } // Deinit NVS only just before the first destructive write so verify/copy failure paths return - // with NVS still functional. The guard re-inits on early returns; success disarms it. + // with NVS still functional. From this point on, components that hold open NVS handles + // (e.g. preferences) will fail with ESP_ERR_NVS_INVALID_HANDLE on success or failure; + // nvs_flash_init() can re-init the subsystem but cannot revive existing handles. On the + // success path the device reboots immediately afterwards so this doesn't matter; on the + // failure path the user must reboot the device before retrying. nvs_flash_deinit(); - NvsReinitGuard nvs_guard; // Update the partition table err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_); @@ -272,7 +263,6 @@ OTAResponseTypes IDFOTABackend::update_partition_table() { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err); return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE; } - nvs_guard.armed = false; return OTA_RESPONSE_OK; } diff --git a/esphome/espota2.py b/esphome/espota2.py index e50b748e98..a45a6ef234 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -138,9 +138,10 @@ _ERROR_MESSAGES: dict[int, str] = { "made to the flash content. Check the logs for more information and retry." ), RESPONSE_ERROR_PARTITION_TABLE_UPDATE: ( - "An error occurred while updating the partition table. The device may " - "not be able to reboot to a working application. Check the logs and retry " - "the update without rebooting the device." + "An error occurred while updating the partition table. The device is now " + "in a degraded state (NVS handles are invalid; many components will fail) " + "and may not be able to boot. Check the logs, reboot the device, and " + "retry the update. If the device fails to boot, recover it via a serial flash." ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } From 1f9afebbf539b1c51ee80020882f81d8d9b3987b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:52:55 -0500 Subject: [PATCH 69/70] [ota] Make partition-table verify errors actionable When the running app does not fit any slot in the new partition table, the user has almost certainly picked the wrong migration .bin for their device. Replace the generic "No compatible app partition found" error with one that prints the running app's offset, used size, and the size limit a migration method must clear, plus an explicit reassurance that no flash content has been modified yet. Same treatment for the otadata-overlap case: include the running app offset/size and tell the user to pick a different migration method. Verification phase is non-destructive, so these errors are recoverable by retrying with the correct .bin -- the new wording makes that explicit so users don't lose time on a brick scare they aren't in. No code-flow change; only log message wording. --- esphome/components/ota/ota_partitions_esp_idf.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index 3a79136c61..2a2ed577f1 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -131,7 +131,14 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a } if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) { - ESP_LOGE(TAG, "No compatible app partition found in the new partition table"); + // Most likely cause: the user picked the wrong migration .bin for their running app's size. + // Rejecting here is non-destructive (no flash op has run yet); the user can safely retry with + // a different .bin. Log enough info that they can pick the right method without guessing. + ESP_LOGE(TAG, + "Running app at 0x%X (%u bytes used) does not fit any compatible slot in the new " + "partition table. Pick a migration method whose size limit is at least %u bytes and " + "retry; no flash content was modified.", + running_app_offset, running_app_size, running_app_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (app_partitions_found < 2) { @@ -147,7 +154,11 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } if (otadata_overlap) { - ESP_LOGE(TAG, "New otadata partition overlaps with running app"); + ESP_LOGE(TAG, + "New otadata partition overlaps with the running app at 0x%X (size %u). The chosen " + "partition table is not compatible with this device's current flash layout; pick a " + "different migration method.", + running_app_offset, running_app_size); return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY; } From da06bb9c7f66d81b22784b2fa2ecec4316c44b43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 18:19:24 -0500 Subject: [PATCH 70/70] [ota] Skip safe_shutdown after partition-table OTA For OTA_TYPE_UPDATE_PARTITION_TABLE the success path runs nvs_flash_deinit() before the final write, which leaves every preference handle held by other components invalid. App.safe_reboot() calls on_safe_shutdown() which tries to flush preferences, and each flush fails with ESP_ERR_NVS_INVALID_HANDLE -- noisy log spam during the reboot window. App.reboot() skips the safe-shutdown callbacks and goes straight to esp_restart(). For partition-table OTAs there is nothing useful to flush (NVS handles are already dead, the device is moments from a reboot anyway), so reboot directly. App-OTA path is unchanged: safe_reboot() still runs on_safe_shutdown so preferences are saved on the way out. --- esphome/components/esphome/ota/ota_esphome.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e6e75bd6e0..3ce3f2302d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -498,6 +498,13 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_COMPLETED, 100.0f, 0); #endif delay(100); // NOLINT +#ifdef USE_OTA_PARTITIONS + if (ota_type == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { + // Skip on_safe_shutdown: nvs_flash_deinit() has already invalidated open NVS handles, so + // preferences flush would emit ESP_ERR_NVS_INVALID_HANDLE for every entry. Reboot directly. + App.reboot(); + } +#endif App.safe_reboot(); error: