diff --git a/CODEOWNERS b/CODEOWNERS index 667a44fc03..fee0e98f46 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -155,6 +155,7 @@ esphome/components/esp32_ble_tracker/* @bdraco esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 +esphome/components/esp32_hosted/update/* @swoboda1337 esphome/components/esp32_improv/* @jesserockz esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py new file mode 100644 index 0000000000..040f989a64 --- /dev/null +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -0,0 +1,78 @@ +import hashlib +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 + +CODEOWNERS = ["@swoboda1337"] +AUTO_LOAD = ["sha256", "watchdog"] +DEPENDENCIES = ["esp32_hosted"] + +CONF_SHA256 = "sha256" + +esp32_hosted_ns = cg.esphome_ns.namespace("esp32_hosted") +Esp32HostedUpdate = esp32_hosted_ns.class_( + "Esp32HostedUpdate", update.UpdateEntity, cg.Component +) + + +def _validate_sha256(value: Any) -> str: + value = cv.string_strict(value) + if len(value) != 64: + raise cv.Invalid("SHA256 must be 64 hexadecimal characters") + try: + bytes.fromhex(value) + except ValueError as e: + raise cv.Invalid(f"SHA256 must be valid hexadecimal: {e}") from e + return value + + +CONFIG_SCHEMA = cv.All( + update.update_schema(Esp32HostedUpdate, device_class="firmware").extend( + { + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + cv.Required(CONF_PATH): cv.file_, + cv.Required(CONF_SHA256): _validate_sha256, + } + ), + esp32.only_on_variant( + supported=[ + esp32.const.VARIANT_ESP32H2, + esp32.const.VARIANT_ESP32P4, + ] + ), +) + + +def _validate_firmware(config: dict[str, Any]) -> None: + path = CORE.relative_config_path(config[CONF_PATH]) + with open(path, "rb") as f: + firmware_data = f.read() + calculated = hashlib.sha256(firmware_data).hexdigest() + expected = config[CONF_SHA256].lower() + if calculated != expected: + raise cv.Invalid( + f"SHA256 mismatch for {config[CONF_PATH]}: expected {expected}, got {calculated}" + ) + + +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) + + 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 new file mode 100644 index 0000000000..adbcc5bf11 --- /dev/null +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -0,0 +1,164 @@ +#if defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) +#include "esp32_hosted_update.h" +#include "esphome/components/watchdog/watchdog.h" +#include "esphome/components/sha256/sha256.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include +#include + +extern "C" { +#include +} + +namespace esphome::esp32_hosted { + +static const char *const TAG = "esp32_hosted.update"; + +// older coprocessor firmware versions have a 1500-byte limit per RPC call +constexpr size_t CHUNK_SIZE = 1500; + +void Esp32HostedUpdate::setup() { + this->update_info_.title = "ESP32 Hosted Coprocessor"; + + // 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); + } else { + this->update_info_.current_version = "unknown"; + } + ESP_LOGD(TAG, "Coprocessor version: %s", this->update_info_.current_version.c_str()); + + // get image version + 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); + if (app_desc->magic_word == ESP_APP_DESC_MAGIC_WORD) { + ESP_LOGD(TAG, "Firmware version: %s", app_desc->version); + ESP_LOGD(TAG, "Project name: %s", app_desc->project_name); + ESP_LOGD(TAG, "Build date: %s", app_desc->date); + ESP_LOGD(TAG, "Build time: %s", app_desc->time); + ESP_LOGD(TAG, "IDF version: %s", app_desc->idf_ver); + this->update_info_.latest_version = app_desc->version; + if (this->update_info_.latest_version != this->update_info_.current_version) { + this->state_ = update::UPDATE_STATE_AVAILABLE; + } else { + this->state_ = update::UPDATE_STATE_NO_UPDATE; + } + } else { + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, + ESP_APP_DESC_MAGIC_WORD); + this->state_ = update::UPDATE_STATE_NO_UPDATE; + } + } else { + ESP_LOGW(TAG, "Firmware too small to contain app description"); + this->state_ = update::UPDATE_STATE_NO_UPDATE; + } + + // publish state + this->status_clear_error(); + this->publish_state(); +} + +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(), + this->firmware_size_); +} + +void Esp32HostedUpdate::perform(bool force) { + if (this->state_ != update::UPDATE_STATE_AVAILABLE && !force) { + ESP_LOGW(TAG, "Update not available"); + return; + } + + if (this->firmware_data_ == nullptr || this->firmware_size_ == 0) { + ESP_LOGE(TAG, "No firmware data available"); + return; + } + + sha256::SHA256 hasher; + hasher.init(); + hasher.add(this->firmware_data_, this->firmware_size_); + hasher.calculate(); + if (!hasher.equals_bytes(this->firmware_sha256_.data())) { + this->status_set_error("SHA256 verification failed"); + this->publish_state(); + return; + } + + 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("Failed to begin OTA"); + this->publish_state(); + return; + } + + uint8_t chunk[CHUNK_SIZE]; + const uint8_t *data_ptr = this->firmware_data_; + size_t remaining = this->firmware_size_; + while (remaining > 0) { + size_t chunk_size = std::min(remaining, static_cast(CHUNK_SIZE)); + memcpy(chunk, data_ptr, chunk_size); + err = esp_hosted_slave_ota_write(chunk, chunk_size); // 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 + this->state_ = prev_state; + this->status_set_error("Failed to write OTA data"); + this->publish_state(); + return; + } + 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)); + this->state_ = prev_state; + this->status_set_error("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)); + this->state_ = prev_state; + this->status_set_error("Failed to activate OTA"); + this->publish_state(); + return; + } + + // 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 + ESP_LOGI(TAG, "Restarting in 1 second"); + this->set_timeout(1000, []() { App.safe_reboot(); }); +} + +} // namespace esphome::esp32_hosted +#endif diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h new file mode 100644 index 0000000000..9c087bf72a --- /dev/null +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -0,0 +1,32 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) + +#include "esphome/core/component.h" +#include "esphome/components/update/update_entity.h" +#include + +namespace esphome::esp32_hosted { + +class Esp32HostedUpdate : public update::UpdateEntity, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + void perform(bool force) override; + void check() override {} + + 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; } + + protected: + const uint8_t *firmware_data_{nullptr}; + size_t firmware_size_{0}; + std::array firmware_sha256_; +}; + +} // namespace esphome::esp32_hosted + +#endif diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1a6dc8b97d..31112caf0a 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -6,15 +6,15 @@ dependencies: espressif/mdns: version: 1.8.2 espressif/esp_wifi_remote: - version: 0.10.2 + version: 1.1.5 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: - version: 0.2.0 + version: 1.1.3 rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.0.11 + version: 2.6.1 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/script/ci-custom.py b/script/ci-custom.py index 6b01623d92..106aa438fe 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -71,6 +71,7 @@ ignore_types = ( ".apng", ".gif", ".webp", + ".bin", ) LINT_FILE_CHECKS = [] diff --git a/tests/components/esp32_hosted/.gitattributes b/tests/components/esp32_hosted/.gitattributes new file mode 100644 index 0000000000..6abdc56117 --- /dev/null +++ b/tests/components/esp32_hosted/.gitattributes @@ -0,0 +1 @@ +*.bin -text diff --git a/tests/components/esp32_hosted/test.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test.esp32-p4-idf.yaml index dade44d145..2a76f17e2f 100644 --- a/tests/components/esp32_hosted/test.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test.esp32-p4-idf.yaml @@ -1 +1,7 @@ <<: !include common.yaml + +update: + - platform: esp32_hosted + name: "Coprocessor Firmware Update" + path: $component_dir/test_firmware.bin + sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31 diff --git a/tests/components/esp32_hosted/test_firmware.bin b/tests/components/esp32_hosted/test_firmware.bin new file mode 100644 index 0000000000..c97c12f9b0 Binary files /dev/null and b/tests/components/esp32_hosted/test_firmware.bin differ