diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index fff0d3623a..b258a26b08 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -4,18 +4,24 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, update import esphome.config_validation as cv -from esphome.const import CONF_PATH, CONF_RAW_DATA_ID -from esphome.core import CORE, HexInt +from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE +from esphome.core import CORE, ID, HexInt CODEOWNERS = ["@swoboda1337"] -AUTO_LOAD = ["sha256", "watchdog"] +AUTO_LOAD = ["sha256", "watchdog", "json"] DEPENDENCIES = ["esp32_hosted"] CONF_SHA256 = "sha256" +CONF_HTTP_REQUEST_ID = "http_request_id" + +TYPE_EMBEDDED = "embedded" +TYPE_HTTP = "http" esp32_hosted_ns = cg.esphome_ns.namespace("esp32_hosted") +http_request_ns = cg.esphome_ns.namespace("http_request") +HttpRequestComponent = http_request_ns.class_("HttpRequestComponent", cg.Component) Esp32HostedUpdate = esp32_hosted_ns.class_( - "Esp32HostedUpdate", update.UpdateEntity, cg.Component + "Esp32HostedUpdate", update.UpdateEntity, cg.PollingComponent ) @@ -30,12 +36,29 @@ def _validate_sha256(value: Any) -> str: return value +BASE_SCHEMA = update.update_schema(Esp32HostedUpdate, device_class="firmware").extend( + cv.polling_component_schema("6h") +) + +EMBEDDED_SCHEMA = BASE_SCHEMA.extend( + { + cv.Required(CONF_PATH): cv.file_, + cv.Required(CONF_SHA256): _validate_sha256, + } +) + +HTTP_SCHEMA = BASE_SCHEMA.extend( + { + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.Required(CONF_SOURCE): cv.url, + } +) + CONFIG_SCHEMA = cv.All( - update.update_schema(Esp32HostedUpdate, device_class="firmware").extend( + cv.typed_schema( { - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_PATH): cv.file_, - cv.Required(CONF_SHA256): _validate_sha256, + TYPE_EMBEDDED: EMBEDDED_SCHEMA, + TYPE_HTTP: HTTP_SCHEMA, } ), esp32.only_on_variant( @@ -48,6 +71,9 @@ CONFIG_SCHEMA = cv.All( def _validate_firmware(config: dict[str, Any]) -> None: + if config[CONF_TYPE] != TYPE_EMBEDDED: + return + path = CORE.relative_config_path(config[CONF_PATH]) with open(path, "rb") as f: firmware_data = f.read() @@ -65,14 +91,22 @@ FINAL_VALIDATE_SCHEMA = _validate_firmware async def to_code(config: dict[str, Any]) -> None: var = await update.new_update(config) - path = config[CONF_PATH] - with open(CORE.relative_config_path(path), "rb") as f: - firmware_data = f.read() - rhs = [HexInt(x) for x in firmware_data] - prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) + if config[CONF_TYPE] == TYPE_EMBEDDED: + path = config[CONF_PATH] + with open(CORE.relative_config_path(path), "rb") as f: + firmware_data = f.read() + rhs = [HexInt(x) for x in firmware_data] + arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) + prog_arr = cg.progmem_array(arr_id, rhs) + + sha256_bytes = bytes.fromhex(config[CONF_SHA256]) + cg.add(var.set_firmware_sha256([HexInt(b) for b in sha256_bytes])) + cg.add(var.set_firmware_data(prog_arr)) + cg.add(var.set_firmware_size(len(firmware_data))) + else: + http_request_var = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) + cg.add(var.set_http_request_parent(http_request_var)) + cg.add(var.set_source_url(config[CONF_SOURCE])) + cg.add_define("USE_ESP32_HOSTED_HTTP_UPDATE") - sha256_bytes = bytes.fromhex(config[CONF_SHA256]) - cg.add(var.set_firmware_sha256([HexInt(b) for b in sha256_bytes])) - cg.add(var.set_firmware_data(prog_arr)) - cg.add(var.set_firmware_size(len(firmware_data))) await cg.register_component(var, config) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 3598a2e69c..fcec1a5f20 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -7,6 +7,12 @@ #include #include #include +#include + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +#include "esphome/components/json/json_util.h" +#include "esphome/components/network/util.h" +#endif extern "C" { #include @@ -16,18 +22,50 @@ namespace esphome::esp32_hosted { static const char *const TAG = "esp32_hosted.update"; -// older coprocessor firmware versions have a 1500-byte limit per RPC call +// Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +// Compile-time version string from esp_hosted_host_fw_ver.h macros +#define STRINGIFY_(x) #x +#define STRINGIFY(x) STRINGIFY_(x) +static const char *const ESP_HOSTED_VERSION_STR = STRINGIFY(ESP_HOSTED_VERSION_MAJOR_1) "." STRINGIFY( + ESP_HOSTED_VERSION_MINOR_1) "." STRINGIFY(ESP_HOSTED_VERSION_PATCH_1); + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +// Parse version string "major.minor.patch" into components +// Returns true if parsing succeeded +static bool parse_version(const std::string &version_str, int &major, int &minor, int &patch) { + major = minor = patch = 0; + if (sscanf(version_str.c_str(), "%d.%d.%d", &major, &minor, &patch) >= 2) { + return true; + } + return false; +} + +// Compare two versions, returns: +// -1 if v1 < v2 +// 0 if v1 == v2 +// 1 if v1 > v2 +static int compare_versions(int major1, int minor1, int patch1, int major2, int minor2, int patch2) { + if (major1 != major2) + return major1 < major2 ? -1 : 1; + if (minor1 != minor2) + return minor1 < minor2 ? -1 : 1; + if (patch1 != patch2) + return patch1 < patch2 ? -1 : 1; + return 0; +} +#endif + void Esp32HostedUpdate::setup() { this->update_info_.title = "ESP32 Hosted Coprocessor"; - // if wifi is not present, connect to the coprocessor #ifndef USE_WIFI + // If WiFi is not present, connect to the coprocessor esp_hosted_connect_to_slave(); // NOLINT #endif - // get coprocessor version + // Get coprocessor version esp_hosted_coprocessor_fwver_t ver_info; if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { this->update_info_.current_version = str_sprintf("%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); @@ -36,7 +74,8 @@ void Esp32HostedUpdate::setup() { } ESP_LOGD(TAG, "Coprocessor version: %s", this->update_info_.current_version.c_str()); - // get image version +#ifndef USE_ESP32_HOSTED_HTTP_UPDATE + // Embedded mode: get image version from embedded firmware const int app_desc_offset = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t); if (this->firmware_size_ >= app_desc_offset + sizeof(esp_app_desc_t)) { esp_app_desc_t *app_desc = (esp_app_desc_t *) (this->firmware_data_ + app_desc_offset); @@ -64,58 +103,272 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } - // publish state + // Publish state this->status_clear_error(); this->publish_state(); +#else + // HTTP mode: retry initial check every 10s until network is ready (max 6 attempts) + // Only if update interval is > 1 minute to avoid redundant checks + if (this->get_update_interval() > 60000) { + this->set_retry("initial_check", 10000, 6, [this](uint8_t) { + if (!network::is_connected()) { + return RetryResult::RETRY; + } + this->check(); + return RetryResult::DONE; + }); + } +#endif } void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, "ESP32 Hosted Update:\n" - " Current Version: %s\n" - " Latest Version: %s\n" - " Latest Size: %zu bytes", - this->update_info_.current_version.c_str(), this->update_info_.latest_version.c_str(), + " Host Library Version: %s\n" + " Coprocessor Version: %s\n" + " Latest Version: %s", + ESP_HOSTED_VERSION_STR, this->update_info_.current_version.c_str(), + this->update_info_.latest_version.c_str()); +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + ESP_LOGCONFIG(TAG, + " Mode: HTTP\n" + " Source URL: %s", + this->source_url_.c_str()); +#else + ESP_LOGCONFIG(TAG, + " Mode: Embedded\n" + " Firmware Size: %zu bytes", this->firmware_size_); +#endif } -void Esp32HostedUpdate::perform(bool force) { - if (this->state_ != update::UPDATE_STATE_AVAILABLE && !force) { - ESP_LOGW(TAG, "Update not available"); +void Esp32HostedUpdate::check() { +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); return; } + if (!this->fetch_manifest_()) { + return; + } + + // Compare versions + if (this->update_info_.latest_version.empty() || + this->update_info_.latest_version == this->update_info_.current_version) { + this->state_ = update::UPDATE_STATE_NO_UPDATE; + } else { + this->state_ = update::UPDATE_STATE_AVAILABLE; + } + + this->update_info_.has_progress = false; + this->update_info_.progress = 0.0f; + this->status_clear_error(); + this->publish_state(); +#endif +} + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +bool Esp32HostedUpdate::fetch_manifest_() { + ESP_LOGD(TAG, "Fetching manifest"); + + auto container = this->http_request_parent_->get(this->source_url_); + if (container == nullptr || container->status_code != 200) { + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + this->status_set_error(LOG_STR("Failed to fetch manifest")); + return false; + } + + // Read manifest JSON into string (manifest is small, ~1KB max) + std::string json_str; + json_str.reserve(container->content_length); + uint8_t buf[256]; + while (container->get_bytes_read() < container->content_length) { + int read = container->read(buf, sizeof(buf)); + if (read > 0) { + json_str.append(reinterpret_cast(buf), read); + } + yield(); + } + container->end(); + + // Parse JSON manifest + // Format: {"versions": [{"version": "2.7.0", "url": "...", "sha256": "..."}]} + // Only consider versions <= host library version to avoid compatibility issues + bool valid = json::parse_json(json_str, [this](JsonObject root) -> bool { + if (!root["versions"].is()) { + ESP_LOGE(TAG, "Manifest does not contain 'versions' array"); + return false; + } + + JsonArray versions = root["versions"].as(); + if (versions.size() == 0) { + ESP_LOGE(TAG, "Manifest 'versions' array is empty"); + return false; + } + + // Find the highest version that is compatible with the host library + // (version <= host version to avoid upgrading coprocessor ahead of host) + int best_major = -1, best_minor = -1, best_patch = -1; + std::string best_version, best_url, best_sha256; + + for (JsonObject entry : versions) { + if (!entry["version"].is() || !entry["url"].is() || + !entry["sha256"].is()) { + continue; // Skip malformed entries + } + + std::string ver_str = entry["version"].as(); + int major, minor, patch; + if (!parse_version(ver_str, major, minor, patch)) { + ESP_LOGW(TAG, "Failed to parse version: %s", ver_str.c_str()); + continue; + } + + // Check if this version is compatible (not newer than host) + if (compare_versions(major, minor, patch, ESP_HOSTED_VERSION_MAJOR_1, ESP_HOSTED_VERSION_MINOR_1, + ESP_HOSTED_VERSION_PATCH_1) > 0) { + continue; + } + + // Check if this is better than our current best + if (best_major < 0 || compare_versions(major, minor, patch, best_major, best_minor, best_patch) > 0) { + best_major = major; + best_minor = minor; + best_patch = patch; + best_version = ver_str; + best_url = entry["url"].as(); + best_sha256 = entry["sha256"].as(); + } + } + + if (best_major < 0) { + ESP_LOGW(TAG, "No compatible firmware version found (host is %s)", ESP_HOSTED_VERSION_STR); + return false; + } + + this->update_info_.latest_version = best_version; + this->firmware_url_ = best_url; + + // Parse SHA256 hex string to bytes + if (!parse_hex(best_sha256, this->firmware_sha256_.data(), 32)) { + ESP_LOGE(TAG, "Invalid SHA256: %s", best_sha256.c_str()); + return false; + } + + ESP_LOGD(TAG, "Best compatible version: %s", this->update_info_.latest_version.c_str()); + + return true; + }); + + if (!valid) { + ESP_LOGE(TAG, "Failed to parse manifest JSON"); + this->status_set_error(LOG_STR("Failed to parse manifest")); + return false; + } + + return true; +} + +bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { + ESP_LOGI(TAG, "Downloading firmware"); + + auto container = this->http_request_parent_->get(this->firmware_url_); + if (container == nullptr || container->status_code != 200) { + ESP_LOGE(TAG, "Failed to fetch firmware"); + this->status_set_error(LOG_STR("Failed to fetch firmware")); + return false; + } + + size_t total_size = container->content_length; + ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size); + + // Begin OTA on coprocessor + esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); + container->end(); + this->status_set_error(LOG_STR("Failed to begin OTA")); + return false; + } + + // Stream firmware to coprocessor while computing SHA256 + // Hardware SHA acceleration requires 32-byte alignment on some chips (ESP32-S3 with IDF 5.5.x+) + alignas(32) sha256::SHA256 hasher; + hasher.init(); + + uint8_t buffer[CHUNK_SIZE]; + while (container->get_bytes_read() < total_size) { + int read = container->read(buffer, sizeof(buffer)); + + // Feed watchdog and give other tasks a chance to run + App.feed_wdt(); + yield(); + + // Exit loop if no data available (stream closed or end of data) + if (read <= 0) { + if (read < 0) { + ESP_LOGE(TAG, "Stream closed with error"); + esp_hosted_slave_ota_end(); // NOLINT + container->end(); + this->status_set_error(LOG_STR("Download failed")); + return false; + } + // read == 0: no more data available, exit loop + break; + } + + hasher.add(buffer, read); + err = esp_hosted_slave_ota_write(buffer, read); // NOLINT + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); + esp_hosted_slave_ota_end(); // NOLINT + container->end(); + this->status_set_error(LOG_STR("Failed to write OTA data")); + return false; + } + } + container->end(); + + // Verify SHA256 + hasher.calculate(); + if (!hasher.equals_bytes(this->firmware_sha256_.data())) { + ESP_LOGE(TAG, "SHA256 mismatch"); + esp_hosted_slave_ota_end(); // NOLINT + this->status_set_error(LOG_STR("SHA256 verification failed")); + return false; + } + + ESP_LOGI(TAG, "SHA256 verified successfully"); + return true; +} +#else +bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { if (this->firmware_data_ == nullptr || this->firmware_size_ == 0) { ESP_LOGE(TAG, "No firmware data available"); - return; + this->status_set_error(LOG_STR("No firmware data available")); + return false; } - // ESP32-S3 hardware SHA acceleration requires 32-byte DMA alignment (IDF 5.5.x+) + // Verify SHA256 before writing + // Hardware SHA acceleration requires 32-byte alignment on some chips (ESP32-S3 with IDF 5.5.x+) alignas(32) sha256::SHA256 hasher; hasher.init(); hasher.add(this->firmware_data_, this->firmware_size_); hasher.calculate(); if (!hasher.equals_bytes(this->firmware_sha256_.data())) { + ESP_LOGE(TAG, "SHA256 mismatch"); this->status_set_error(LOG_STR("SHA256 verification failed")); - this->publish_state(); - return; + return false; } ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_); - watchdog::WatchdogManager watchdog(20000); - update::UpdateState prev_state = this->state_; - this->state_ = update::UPDATE_STATE_INSTALLING; - this->update_info_.has_progress = false; - this->publish_state(); - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); - this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to begin OTA")); - this->publish_state(); - return; + return false; } uint8_t chunk[CHUNK_SIZE]; @@ -128,42 +381,68 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); esp_hosted_slave_ota_end(); // NOLINT - this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to write OTA data")); - this->publish_state(); - return; + return false; } data_ptr += chunk_size; remaining -= chunk_size; App.feed_wdt(); } - err = esp_hosted_slave_ota_end(); // NOLINT - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(err)); + return true; +} +#endif + +void Esp32HostedUpdate::perform(bool force) { + if (this->state_ != update::UPDATE_STATE_AVAILABLE && !force) { + ESP_LOGW(TAG, "Update not available"); + return; + } + + update::UpdateState prev_state = this->state_; + this->state_ = update::UPDATE_STATE_INSTALLING; + this->update_info_.has_progress = false; + this->publish_state(); + + watchdog::WatchdogManager watchdog(60000); + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + if (!this->stream_firmware_to_coprocessor_()) +#else + if (!this->write_embedded_firmware_to_coprocessor_()) +#endif + { + this->state_ = prev_state; + this->publish_state(); + return; + } + + // End OTA and activate new firmware + esp_err_t end_err = esp_hosted_slave_ota_end(); // NOLINT + if (end_err != ESP_OK) { + ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err)); this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to end OTA")); this->publish_state(); return; } - // activate new firmware - err = esp_hosted_slave_ota_activate(); // NOLINT - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to activate OTA: %s", esp_err_to_name(err)); + esp_err_t activate_err = esp_hosted_slave_ota_activate(); // NOLINT + if (activate_err != ESP_OK) { + ESP_LOGE(TAG, "Failed to activate OTA: %s", esp_err_to_name(activate_err)); this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to activate OTA")); this->publish_state(); return; } - // update state + // Update state ESP_LOGI(TAG, "OTA update successful"); this->state_ = update::UPDATE_STATE_NO_UPDATE; this->status_clear_error(); this->publish_state(); - // schedule a restart to ensure everything is in sync + // Schedule a restart to ensure everything is in sync ESP_LOGI(TAG, "Restarting in 1 second"); this->set_timeout(1000, []() { App.safe_reboot(); }); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 9c087bf72a..7c9645c12a 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -5,26 +5,55 @@ #include "esphome/core/component.h" #include "esphome/components/update/update_entity.h" #include +#include + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +#include "esphome/components/http_request/http_request.h" +#endif namespace esphome::esp32_hosted { -class Esp32HostedUpdate : public update::UpdateEntity, public Component { +class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { public: void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void update() override { this->check(); } // PollingComponent - delegates to check() void perform(bool force) override; - void check() override {} + void check() override; +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + // HTTP mode setters + void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } +#else + // Embedded mode setters void set_firmware_data(const uint8_t *data) { this->firmware_data_ = data; } void set_firmware_size(size_t size) { this->firmware_size_ = size; } void set_firmware_sha256(const std::array &sha256) { this->firmware_sha256_ = sha256; } +#endif protected: +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + // HTTP mode members + http_request::HttpRequestComponent *http_request_parent_{nullptr}; + std::string source_url_; + std::string firmware_url_; + + // HTTP mode helpers + bool fetch_manifest_(); + bool stream_firmware_to_coprocessor_(); +#else + // Embedded mode members const uint8_t *firmware_data_{nullptr}; size_t firmware_size_{0}; - std::array firmware_sha256_; + + // Embedded mode helper + bool write_embedded_firmware_to_coprocessor_(); +#endif + + std::array firmware_sha256_{}; }; } // namespace esphome::esp32_hosted diff --git a/tests/components/esp32_hosted/test.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml similarity index 92% rename from tests/components/esp32_hosted/test.esp32-p4-idf.yaml rename to tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml index 2a76f17e2f..9640032b34 100644 --- a/tests/components/esp32_hosted/test.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml @@ -3,5 +3,6 @@ update: - platform: esp32_hosted name: "Coprocessor Firmware Update" + type: embedded path: $component_dir/test_firmware.bin sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31 diff --git a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml new file mode 100644 index 0000000000..17cde0f35d --- /dev/null +++ b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml @@ -0,0 +1,10 @@ +<<: !include common.yaml + +http_request: + +update: + - platform: esp32_hosted + name: "Coprocessor Firmware Update" + type: http + source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json + update_interval: 6h