diff --git a/CODEOWNERS b/CODEOWNERS index c630db7948..8c8af2d681 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -254,6 +254,7 @@ esphome/components/nfc/* @jesserockz @kbx81 esphome/components/noblex/* @AGalfra esphome/components/number/* @esphome/core esphome/components/ota/* @esphome/core +esphome/components/ota_http/* @oarcher esphome/components/output/* @esphome/core esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @clydebarrow @hwstar diff --git a/esphome/components/ota_http/__init__.py b/esphome/components/ota_http/__init__.py new file mode 100644 index 0000000000..dd05b8aa9c --- /dev/null +++ b/esphome/components/ota_http/__init__.py @@ -0,0 +1,198 @@ +import urllib.parse as urlparse +import esphome.codegen as cg +import esphome.config_validation as cv +import esphome.final_validate as fv +from esphome import automation +from esphome.const import ( + CONF_ID, + CONF_TIMEOUT, + CONF_URL, + CONF_METHOD, + CONF_ESP8266_DISABLE_SSL_SUPPORT, + CONF_SAFE_MODE, + CONF_FORCE_UPDATE, +) +from esphome.components import esp32 +from esphome.core import CORE, coroutine_with_priority + +CODEOWNERS = ["@oarcher"] + +DEPENDENCIES = ["network"] +AUTO_LOAD = ["md5", "ota"] + +ota_http_ns = cg.esphome_ns.namespace("ota_http") +OtaHttpComponent = ota_http_ns.class_("OtaHttpComponent", cg.Component) +OtaHttpArduino = ota_http_ns.class_("OtaHttpArduino", OtaHttpComponent) +OtaHttpIDF = ota_http_ns.class_("OtaHttpIDF", OtaHttpComponent) + +OtaHttpFlashAction = ota_http_ns.class_("OtaHttpFlashAction", automation.Action) + +CONF_EXCLUDE_CERTIFICATE_BUNDLE = "exclude_certificate_bundle" +CONF_MD5_URL = "md5_url" +CONF_WATCHDOG_TIMEOUT = "watchdog_timeout" +CONF_MAX_URL_LENGTH = "max_url_length" + + +def validate_certificate_bundle(config): + if not (CORE.is_esp8266 and config.get(CONF_ESP8266_DISABLE_SSL_SUPPORT)) and ( + not config.get(CONF_EXCLUDE_CERTIFICATE_BUNDLE) and not CORE.using_esp_idf + ): + raise cv.Invalid( + "ESPHome supports certificate verification only via ESP-IDF. " + f"Set '{CONF_EXCLUDE_CERTIFICATE_BUNDLE}: true' to skip certificate validation." + ) + + return config + + +def validate_url(value): + value = cv.string(value) + try: + parsed = list(urlparse.urlparse(value)) + except Exception as err: + raise cv.Invalid("Invalid URL") from err + + if not parsed[0] or not parsed[1]: + raise cv.Invalid("URL must have a URL scheme and host") + + if parsed[0] not in ["http", "https"]: + raise cv.Invalid("Scheme must be http or https") + + if not parsed[2]: + parsed[2] = "/" + + return urlparse.urlunparse(parsed) + + +def validate_safe_mode(config): + # using 'safe_mode' on 'esp8266' require 'restore_from_flash' + if CORE.is_esp8266 and config[CONF_SAFE_MODE]: + if not fv.full_config.get()["esp8266"]["restore_from_flash"]: + raise cv.Invalid( + "Using 'safe_mode' on 'esp8266' require 'restore_from_flash'." + "See https://esphome.io/components/esp8266#configuration-variables" + ) + return config + + +def _declare_request_class(value): + if CORE.using_esp_idf: + return cv.declare_id(OtaHttpIDF)(value) + + if CORE.is_esp8266 or CORE.is_esp32 or CORE.is_rp2040: + return cv.declare_id(OtaHttpArduino)(value) + return NotImplementedError + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): _declare_request_class, + cv.Optional( + CONF_TIMEOUT, default="5min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_WATCHDOG_TIMEOUT): cv.All( + cv.Any(cv.only_on_esp32, cv.only_on_rp2040), + cv.positive_time_period_milliseconds, + ), + cv.SplitDefault(CONF_ESP8266_DISABLE_SSL_SUPPORT, esp8266=False): cv.All( + cv.only_on_esp8266, cv.boolean + ), + cv.Optional(CONF_EXCLUDE_CERTIFICATE_BUNDLE, default=False): cv.boolean, + cv.Optional(CONF_SAFE_MODE, default="fallback"): cv.Any( + cv.boolean, "fallback" + ), + cv.Optional(CONF_MAX_URL_LENGTH, default=240): cv.uint16_t, + cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.require_framework_version( + esp8266_arduino=cv.Version(2, 5, 1), + esp32_arduino=cv.Version(0, 0, 0), + esp_idf=cv.Version(0, 0, 0), + rp2040_arduino=cv.Version(0, 0, 0), + ), + validate_certificate_bundle, +) + +FINAL_VALIDATE_SCHEMA = cv.All(validate_safe_mode) + + +@coroutine_with_priority(50.0) +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + cg.add_define("CONFIG_MAX_URL_LENGTH", config[CONF_MAX_URL_LENGTH]) + cg.add_define("CONFIG_FORCE_UPDATE", config[CONF_FORCE_UPDATE]) + if ( + config.get(CONF_WATCHDOG_TIMEOUT, None) + and config[CONF_WATCHDOG_TIMEOUT].total_milliseconds > 0 + ): + cg.add_define( + "CONFIG_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT].total_milliseconds + ) + + if CORE.is_esp8266 and not config[CONF_ESP8266_DISABLE_SSL_SUPPORT]: + cg.add_define("USE_HTTP_REQUEST_ESP8266_HTTPS") + + if CORE.is_esp32: + if CORE.using_esp_idf: + esp32.add_idf_sdkconfig_option( + "CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", + not config.get(CONF_EXCLUDE_CERTIFICATE_BUNDLE), + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_TLS_INSECURE", + config.get(CONF_EXCLUDE_CERTIFICATE_BUNDLE), + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", + config.get(CONF_EXCLUDE_CERTIFICATE_BUNDLE), + ) + else: + cg.add_library("WiFiClientSecure", None) + cg.add_library("HTTPClient", None) + if CORE.is_esp8266: + cg.add_library("ESP8266HTTPClient", None) + if CORE.is_rp2040 and CORE.using_arduino: + cg.add_library("HTTPClient", None) + + await cg.register_component(var, config) + + if config[CONF_SAFE_MODE]: + if config[CONF_SAFE_MODE] is True: + cg.add_define("OTA_HTTP_ONLY_AT_BOOT") + cg.add(var.check_upgrade()) + + +OTA_HTTP_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(OtaHttpComponent), + cv.Required(CONF_MD5_URL): cv.templatable(validate_url), + cv.Required(CONF_URL): cv.templatable(validate_url), + } +) + + +OTA_HTTP_FLASH_ACTION_SCHEMA = automation.maybe_conf( + CONF_URL, + OTA_HTTP_ACTION_SCHEMA.extend( + { + cv.Optional(CONF_METHOD, default="flash"): cv.string, + } + ), +) + + +@automation.register_action( + "ota_http.flash", OtaHttpFlashAction, OTA_HTTP_FLASH_ACTION_SCHEMA +) +async def ota_http_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + template_ = await cg.templatable(config[CONF_MD5_URL], args, cg.std_string) + cg.add(var.set_md5_url(template_)) + template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) + cg.add(var.set_url(template_)) + return var diff --git a/esphome/components/ota_http/ota_http.cpp b/esphome/components/ota_http/ota_http.cpp new file mode 100644 index 0000000000..0656fbb616 --- /dev/null +++ b/esphome/components/ota_http/ota_http.cpp @@ -0,0 +1,345 @@ +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/components/md5/md5.h" +#include "esphome/components/ota/ota_backend_arduino_esp32.h" +#include "esphome/components/ota/ota_backend_arduino_esp8266.h" +#include "esphome/components/ota/ota_backend_arduino_rp2040.h" +#include "esphome/components/ota/ota_backend_esp_idf.h" +#include "esphome/components/ota/ota_backend.h" +#include "ota_http.h" + +#ifdef USE_ESP8266 +#include "esphome/components/esp8266/preferences.h" +#endif +#ifdef USE_RP2040 +#include "esphome/components/rp2040/preferences.h" +#endif + +#ifdef CONFIG_WATCHDOG_TIMEOUT +#include "watchdog.h" +#endif + +namespace esphome { +namespace ota_http { + +std::unique_ptr make_ota_backend() { +#ifdef USE_ESP8266 + ESP_LOGD(TAG, "Using ArduinoESP8266OTABackend"); + return make_unique(); +#endif // USE_ESP8266 + +#ifdef USE_ARDUINO +#ifdef USE_ESP32 + ESP_LOGD(TAG, "Using ArduinoESP32OTABackend"); + return make_unique(); +#endif // USE_ESP32 +#endif // USE_ARDUINO + +#ifdef USE_ESP_IDF + ESP_LOGD(TAG, "Using IDFOTABackend"); + return make_unique(); +#endif // USE_ESP_IDF +#ifdef USE_RP2040 + ESP_LOGD(TAG, "Using ArduinoRP2040OTABackend"); + return make_unique(); +#endif // USE_RP2040 + ESP_LOGE(TAG, "No OTA backend!"); +} + +const std::unique_ptr OtaHttpComponent::BACKEND = make_ota_backend(); + +OtaHttpComponent::OtaHttpComponent() { + this->pref_obj_.load(&this->pref_); + if (!this->pref_obj_.save(&this->pref_)) { + // error at 'load' might be caused by 1st usage, but error at 'save' is a real error. + ESP_LOGE(TAG, "Unable to use flash memory. Safe mode might be not available"); + } +} + +void OtaHttpComponent::dump_config() { + ESP_LOGCONFIG(TAG, "OTA Update over http:"); + pref_.last_md5[MD5_SIZE] = '\0'; + ESP_LOGCONFIG(TAG, " Last flashed md5: %s", pref_.last_md5); + ESP_LOGCONFIG(TAG, " Max url length: %d", CONFIG_MAX_URL_LENGTH); + ESP_LOGCONFIG(TAG, " Timeout: %llus", this->timeout_ / 1000); +#ifdef CONFIG_WATCHDOG_TIMEOUT + ESP_LOGCONFIG(TAG, " Watchdog timeout: %ds", CONFIG_WATCHDOG_TIMEOUT / 1000); +#endif +#ifdef OTA_HTTP_ONLY_AT_BOOT + ESP_LOGCONFIG(TAG, " Safe mode: Yes"); +#else + ESP_LOGCONFIG(TAG, " Safe mode: %s", this->safe_mode_ ? "Fallback" : "No"); +#endif +#ifdef CONFIG_MBEDTLS_CERTIFICATE_BUNDLE + ESP_LOGCONFIG(TAG, " TLS server verification: Yes"); +#else + ESP_LOGCONFIG(TAG, " TLS server verification: No"); +#endif +#ifdef USE_ESP8266 +#ifdef USE_HTTP_REQUEST_ESP8266_HTTPS + ESP_LOGCONFIG(TAG, " ESP8266 SSL support: No"); +#else + ESP_LOGCONFIG(TAG, " ESP8266 SSL support: Yes"); +#endif +#endif +}; + +void OtaHttpComponent::flash() { + if (this->pref_.ota_http_state != OTA_HTTP_STATE_SAFE_MODE) { + ESP_LOGV(TAG, "Setting state to 'progress'"); + this->pref_.ota_http_state = OTA_HTTP_STATE_PROGRESS; + this->pref_obj_.save(&this->pref_); + } + + global_preferences->sync(); + +#ifdef OTA_HTTP_ONLY_AT_BOOT + if (this->pref_.ota_http_state != OTA_HTTP_STATE_SAFE_MODE) { + ESP_LOGI(TAG, "Rebooting before flashing new firmware"); + App.safe_reboot(); + } +#endif +#ifdef CONFIG_WATCHDOG_TIMEOUT + watchdog::Watchdog::set_timeout(CONFIG_WATCHDOG_TIMEOUT); +#endif + uint32_t update_start_time = millis(); + uint8_t buf[this->http_recv_buffer_ + 1]; + int error_code = 0; + uint32_t last_progress = 0; + md5::MD5Digest md5_receive; + std::unique_ptr md5_receive_str(new char[33]); + if (!this->http_get_md5()) { + return; + } + + ESP_LOGD(TAG, "MD5 expected: %s", this->md5_expected_); + + if (!CONFIG_FORCE_UPDATE) { + if (strncmp(this->pref_.last_md5, this->md5_expected_, MD5_SIZE) == 0) { + this->http_end(); + ESP_LOGW(TAG, "OTA Update skipped: retrieved md5 %s match the last installed firmware", this->pref_.last_md5); +#ifdef CONFIG_WATCHDOG_TIMEOUT + watchdog::Watchdog::reset(); +#endif + return; + } + } + + if (!this->set_url(this->pref_.url)) + return; + ESP_LOGI(TAG, "Trying to connect to url: %s", this->safe_url_); + this->http_init(); + if (!this->check_status()) { + this->http_end(); + return; + } + + // we will compute MD5 on the fly for verification -- Arduino OTA seems to ignore it + md5_receive.init(); + ESP_LOGV(TAG, "MD5Digest initialized"); + + error_code = esphome::ota_http::OtaHttpComponent::BACKEND->begin(this->body_length_); + if (error_code != 0) { + ESP_LOGW(TAG, "BACKEND->begin error: %d", error_code); + this->cleanup_(); + return; + } + ESP_LOGI(TAG, "OTA backend begin"); + + this->bytes_read_ = 0; + while (this->bytes_read_ != this->body_length_) { + // read a maximum of chunk_size bytes into buf. (real read size returned) + int bufsize = this->http_read(buf, this->http_recv_buffer_); + + // feed watchdog and give other tasks a chance to run + App.feed_wdt(); + yield(); + + if (bufsize < 0) { + ESP_LOGE(TAG, "Stream closed"); + this->cleanup_(); + return; + } + + // add read bytes to MD5 + md5_receive.add(buf, bufsize); + + // write bytes to OTA backend + this->update_started_ = true; + error_code = ota_http::OtaHttpComponent::BACKEND->write(buf, bufsize); + if (error_code != 0) { + // error code explaination available at + // https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_component.h + ESP_LOGE(TAG, "Error code (%d) writing binary data to flash at offset %d and size %d", error_code, + this->bytes_read_ - bufsize, this->body_length_); + this->cleanup_(); + return; + } + + uint32_t now = millis(); + if ((now - last_progress > 1000) or (this->bytes_read_ == this->body_length_)) { + last_progress = now; + ESP_LOGI(TAG, "Progress: %0.1f%%", this->bytes_read_ * 100. / this->body_length_); + } + } // while + + ESP_LOGI(TAG, "Done in %.0f seconds", float(millis() - update_start_time) / 1000); + + // verify MD5 is as expected and act accordingly + md5_receive.calculate(); + md5_receive.get_hex(md5_receive_str.get()); + if (strncmp(md5_receive_str.get(), this->md5_expected_, MD5_SIZE) != 0) { + ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", md5_receive_str.get()); + this->cleanup_(); + return; + } else { + ota_http::OtaHttpComponent::BACKEND->set_update_md5(md5_receive_str.get()); + } + + this->http_end(); + + // feed watchdog and give other tasks a chance to run + App.feed_wdt(); + yield(); + delay(100); // NOLINT + + error_code = ota_http::OtaHttpComponent::BACKEND->end(); + if (error_code != 0) { + ESP_LOGE(TAG, "Error ending OTA (%d)", error_code); + this->cleanup_(); + return; + } + + this->pref_.ota_http_state = OTA_HTTP_STATE_OK; + strncpy(this->pref_.last_md5, this->md5_expected_, MD5_SIZE); + this->pref_obj_.save(&this->pref_); + // on rp2040 and esp8266, reenable write to flash that was disabled by OTA +#ifdef USE_ESP8266 + esp8266::preferences_prevent_write(false); +#endif +#ifdef USE_RP2040 + rp2040::preferences_prevent_write(false); +#endif + global_preferences->sync(); + delay(10); + ESP_LOGI(TAG, "OTA update completed"); + delay(10); + esphome::App.safe_reboot(); +} + +void OtaHttpComponent::cleanup_() { + if (this->update_started_) { + ESP_LOGV(TAG, "Aborting OTA backend"); + ota_http::OtaHttpComponent::BACKEND->abort(); + } + ESP_LOGV(TAG, "Aborting HTTP connection"); + this->http_end(); + if (this->pref_.ota_http_state == OTA_HTTP_STATE_SAFE_MODE) { + ESP_LOGE(TAG, "Previous safe mode unsuccessful; skipped ota_http"); + this->pref_.ota_http_state = OTA_HTTP_STATE_ABORT; + } + this->pref_obj_.save(&this->pref_); +#ifdef CONFIG_WATCHDOG_TIMEOUT + watchdog::Watchdog::reset(); +#endif +}; + +void OtaHttpComponent::check_upgrade() { + // function called at boot time if CONF_SAFE_MODE is True or "fallback" + this->safe_mode_ = true; + if (this->pref_obj_.load(&this->pref_)) { + if (this->pref_.ota_http_state == OTA_HTTP_STATE_PROGRESS) { + // progress at boot time means that there was a problem + + // Delay here to allow power to stabilise before Wi-Fi/Ethernet is initialised. + delay(300); // NOLINT + App.setup(); + + ESP_LOGI(TAG, "Previous ota_http unsuccessful. Retrying..."); + this->pref_.ota_http_state = OTA_HTTP_STATE_SAFE_MODE; + this->pref_obj_.save(&this->pref_); + this->flash(); + return; + } + if (this->pref_.ota_http_state == OTA_HTTP_STATE_SAFE_MODE) { + ESP_LOGE(TAG, "Previous safe mode unsuccessful; skipped ota_http"); + this->pref_.ota_http_state = OTA_HTTP_STATE_ABORT; + this->pref_obj_.save(&this->pref_); + global_preferences->sync(); + } + } +} + +bool OtaHttpComponent::http_get_md5() { + if (!this->set_url(this->pref_.md5_url)) + return false; + ESP_LOGI(TAG, "Trying to connect to url: %s", this->safe_url_); + this->http_init(); + if (!this->check_status()) { + this->http_end(); + return false; + } + int length = this->body_length_; + if (length < 0) { + this->http_end(); + return false; + } + if (length < MD5_SIZE) { + ESP_LOGE(TAG, "MD5 file must be %u bytes; %u bytes reported by HTTP server. Aborting", MD5_SIZE, + this->body_length_); + this->http_end(); + return false; + } + + auto read_len = this->http_read((uint8_t *) this->md5_expected_, MD5_SIZE); + this->http_end(); + + return read_len == MD5_SIZE; +} + +bool OtaHttpComponent::set_url(char *url) { + this->body_length_ = 0; + this->status_ = -1; + this->bytes_read_ = 0; + if (url == nullptr) { + ESP_LOGE(TAG, "Bad url: (nullptr)"); + return false; + } + if (strncmp(url, "http", 4) != 0) { + ESP_LOGE(TAG, "Bad url: %s", url); + return false; + } + this->url_ = url; + this->set_safe_url_(); + return true; +} + +bool OtaHttpComponent::save_url_(const std::string &value, char *url) { + if (value.length() > CONFIG_MAX_URL_LENGTH - 1) { + ESP_LOGE(TAG, "Url max length is %d, and attempted to set url with length %d: %s", CONFIG_MAX_URL_LENGTH, + value.length(), value.c_str()); + return false; + } + strncpy(url, value.c_str(), value.length()); + url[value.length()] = '\0'; // null terminator + this->pref_obj_.save(&this->pref_); + return true; +} + +bool OtaHttpComponent::check_status() { + // status can be -1, or http status code + if (this->status_ < 100) { + ESP_LOGE(TAG, "No answer from http server (error %d). Network error?", this->status_); + return false; + } + if (this->status_ >= 310) { + ESP_LOGE(TAG, "HTTP error %d", this->status_); + return false; + } + ESP_LOGV(TAG, "HTTP status %d", this->status_); + return true; +} + +} // namespace ota_http +} // namespace esphome diff --git a/esphome/components/ota_http/ota_http.h b/esphome/components/ota_http/ota_http.h new file mode 100644 index 0000000000..9eb7ab2099 --- /dev/null +++ b/esphome/components/ota_http/ota_http.h @@ -0,0 +1,122 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/ota/ota_backend.h" + +#include +#include +#include + +namespace esphome { +namespace ota_http { + +enum OtaHttpState { + OTA_HTTP_STATE_OK, + OTA_HTTP_STATE_PROGRESS, + OTA_HTTP_STATE_SAFE_MODE, + OTA_HTTP_STATE_ABORT, +}; + +#define OTA_HTTP_PREF_SAFE_MODE_HASH 99380598UL +#ifndef CONFIG_MAX_URL_LENGTH +static const uint16_t CONFIG_MAX_URL_LENGTH = 128; +#endif +#ifndef CONFIG_FORCE_UPDATE +static const bool CONFIG_FORCE_UPDATE = true; +#endif + +static const char *const TAG = "ota_http"; +static const uint8_t MD5_SIZE = 32; + +struct OtaHttpGlobalPrefType { + OtaHttpState ota_http_state; + char last_md5[MD5_SIZE + 1]; + char md5_url[CONFIG_MAX_URL_LENGTH]; + char url[CONFIG_MAX_URL_LENGTH]; +} PACKED; + +class OtaHttpComponent : public Component { + public: + OtaHttpComponent(); + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + bool save_md5_url(const std::string &md5_url) { return this->save_url_(md5_url, this->pref_.md5_url); } + bool save_url(const std::string &url) { return this->save_url_(url, this->pref_.url); } + bool set_url(char *url); + void set_timeout(uint64_t timeout) { this->timeout_ = timeout; } + void flash(); + void check_upgrade(); + bool http_get_md5(); + bool check_status(); + virtual void http_init(){}; + virtual int http_read(uint8_t *buf, size_t len) { return 0; }; + virtual void http_end(){}; + + protected: + char *url_ = nullptr; + char safe_url_[CONFIG_MAX_URL_LENGTH]; + bool secure_() { return strncmp(this->url_, "https:", 6) == 0; }; + size_t body_length_ = 0; + int status_ = -1; + size_t bytes_read_ = 0; + bool safe_mode_ = false; + uint64_t timeout_; + const uint16_t http_recv_buffer_ = 500; // the firmware GET chunk size + const uint16_t max_http_recv_buffer_ = 512; // internal max http buffer size must be > HTTP_RECV_BUFFER_ (TLS + // overhead) and must be a power of two from 512 to 4096 + bool update_started_ = false; + static const std::unique_ptr BACKEND; + void cleanup_(); + char md5_expected_[MD5_SIZE]; + OtaHttpGlobalPrefType pref_ = {OTA_HTTP_STATE_OK, "None", "", ""}; + ESPPreferenceObject pref_obj_ = + global_preferences->make_preference(OTA_HTTP_PREF_SAFE_MODE_HASH, true); + + private: + bool save_url_(const std::string &value, char *url); + void set_safe_url_() { + // using regex makes 8266 unstable later + const char *prefix_end = strstr(this->url_, "://"); + if (!prefix_end) { + strlcpy(this->safe_url_, this->url_, sizeof(this->safe_url_)); + return; + } + const char *at = strchr(prefix_end, '@'); + if (!at) { + strlcpy(this->safe_url_, this->url_, sizeof(this->safe_url_)); + return; + } + + size_t prefix_len = prefix_end - this->url_ + 3; + strlcpy(this->safe_url_, this->url_, prefix_len + 1); + strlcat(this->safe_url_, "****:****@", sizeof(this->safe_url_)); + + strlcat(this->safe_url_, at + 1, sizeof(this->safe_url_)); + } +}; + +template class OtaHttpFlashAction : public Action { + public: + OtaHttpFlashAction(OtaHttpComponent *parent) : parent_(parent) {} + TEMPLATABLE_VALUE(std::string, md5_url) + TEMPLATABLE_VALUE(std::string, url) + TEMPLATABLE_VALUE(uint64_t, timeout) + + void play(Ts... x) override { + if (this->parent_->save_md5_url(this->md5_url_.value(x...)) && this->parent_->save_url(this->url_.value(x...))) { + if (this->timeout_.has_value()) { + this->parent_->set_timeout(this->timeout_.value(x...)); + } + this->parent_->flash(); + // Normaly never reached (device rebooted) + } + } + + protected: + OtaHttpComponent *parent_; +}; + +} // namespace ota_http +} // namespace esphome diff --git a/esphome/components/ota_http/ota_http_arduino.cpp b/esphome/components/ota_http/ota_http_arduino.cpp new file mode 100644 index 0000000000..b739740358 --- /dev/null +++ b/esphome/components/ota_http/ota_http_arduino.cpp @@ -0,0 +1,129 @@ +#include "ota_http.h" + +#ifdef USE_ARDUINO +#include "ota_http_arduino.h" +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/components/network/util.h" +#include "esphome/components/md5/md5.h" + +namespace esphome { +namespace ota_http { + +struct Header { + const char *name; + const char *value; +}; + +void OtaHttpArduino::http_init() { + const char *header_keys[] = {"Content-Length", "Content-Type"}; + const size_t header_count = sizeof(header_keys) / sizeof(header_keys[0]); + +#ifdef USE_ESP8266 + if (this->stream_ptr_ == nullptr && this->set_stream_ptr_()) { + ESP_LOGE(TAG, "Unable to set client"); + return; + } +#endif // USE_ESP8266 + +#ifdef USE_RP2040 + this->client_.setInsecure(); +#endif + + App.feed_wdt(); + +#if defined(USE_ESP32) || defined(USE_RP2040) + this->status_ = this->client_.begin(this->url_); +#endif +#ifdef USE_ESP8266 + this->status_ = this->client_.begin(*this->stream_ptr_, String(this->url_)); +#endif + + if (!this->status_) { + this->client_.end(); + return; + } + + this->client_.setReuse(true); + + // returned needed headers must be collected before the requests + this->client_.collectHeaders(header_keys, header_count); + + // HTTP GET + this->status_ = this->client_.GET(); + + this->body_length_ = (size_t) this->client_.getSize(); + +#if defined(USE_ESP32) || defined(USE_RP2040) + if (this->stream_ptr_ == nullptr) { + this->set_stream_ptr_(); + } +#endif +} + +int OtaHttpArduino::http_read(uint8_t *buf, const size_t max_len) { +#ifdef USE_ESP8266 +#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 1, 0) // && USE_ARDUINO_VERSION_CODE < VERSION_CODE(?, ?, ?) + if (!this->secure_()) { + ESP_LOGW(TAG, + "Using arduino version >= 3.1 is **very** slow. Consider setting framework version to 3.0.2 in your yaml"); + } +#endif // USE_ARDUINO_VERSION_CODE +#endif // USE_ESP8266 + + // Since arduino8266 >= 3.1 using this->stream_ptr_ is broken (https://github.com/esp8266/Arduino/issues/9035) + WiFiClient *stream_ptr = this->client_.getStreamPtr(); + + // wait for the stream to be populated + while (stream_ptr->available() == 0) { + // give other tasks a chance to run while waiting for some data: + App.feed_wdt(); + yield(); + delay(1); + } + int available_data = stream_ptr->available(); + int bufsize = std::min((int) max_len, available_data); + if (bufsize > 0) { + this->stream_ptr_->readBytes(buf, bufsize); + this->bytes_read_ += bufsize; + buf[bufsize] = '\0'; // not fed to ota + } + + return bufsize; +} + +void OtaHttpArduino::http_end() { this->client_.end(); } + +int OtaHttpArduino::set_stream_ptr_() { +#ifdef USE_ESP8266 +#ifdef USE_HTTP_REQUEST_ESP8266_HTTPS + if (this->secure_()) { + ESP_LOGV(TAG, "ESP8266 HTTPS connection with WiFiClientSecure"); + this->stream_ptr_ = std::make_unique(); + WiFiClientSecure *secure_client = static_cast(this->stream_ptr_.get()); + secure_client->setBufferSizes(this->max_http_recv_buffer_, 512); + secure_client->setInsecure(); + } else { + this->stream_ptr_ = std::make_unique(); + } +#else + ESP_LOGV(TAG, "ESP8266 HTTP connection with WiFiClient"); + if (this->secure_()) { + ESP_LOGE(TAG, "Can't use HTTPS connection with esp8266_disable_ssl_support"); + return -1; + } + this->stream_ptr_ = std::make_unique(); +#endif // USE_HTTP_REQUEST_ESP8266_HTTPS +#endif // USE_ESP8266 + +#if defined(USE_ESP32) || defined(USE_RP2040) + this->stream_ptr_ = std::unique_ptr(this->client_.getStreamPtr()); +#endif + return 0; +} + +} // namespace ota_http +} // namespace esphome + +#endif // USE_ARDUINO diff --git a/esphome/components/ota_http/ota_http_arduino.h b/esphome/components/ota_http/ota_http_arduino.h new file mode 100644 index 0000000000..e679611d55 --- /dev/null +++ b/esphome/components/ota_http/ota_http_arduino.h @@ -0,0 +1,43 @@ +#pragma once + +#include "ota_http.h" + +#ifdef USE_ARDUINO + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +#include +#include +#include + +#if defined(USE_ESP32) || defined(USE_RP2040) +#include +#endif +#ifdef USE_ESP8266 +#include +#ifdef USE_HTTP_REQUEST_ESP8266_HTTPS +#include +#endif +#endif + +namespace esphome { +namespace ota_http { + +class OtaHttpArduino : public OtaHttpComponent { + public: + void http_init() override; + int http_read(uint8_t *buf, size_t len) override; + void http_end() override; + + protected: + int set_stream_ptr_(); + HTTPClient client_{}; + std::unique_ptr stream_ptr_; +}; + +} // namespace ota_http +} // namespace esphome + +#endif // USE_ARDUINO diff --git a/esphome/components/ota_http/ota_http_idf.cpp b/esphome/components/ota_http/ota_http_idf.cpp new file mode 100644 index 0000000000..6b6fd92a88 --- /dev/null +++ b/esphome/components/ota_http/ota_http_idf.cpp @@ -0,0 +1,81 @@ +#ifdef USE_ESP_IDF + +#include "ota_http_idf.h" +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/components/network/util.h" +#include "esphome/components/md5/md5.h" + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_tls.h" +#include "nvs_flash.h" +#include "esp_task_wdt.h" +#include "esp_idf_version.h" +#include +#include +#include +#include +#include +#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE +#include "esp_crt_bundle.h" +#endif + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_system.h" + +#include "esp_http_client.h" + +namespace esphome { +namespace ota_http { + +void OtaHttpIDF::http_init() { + App.feed_wdt(); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" + esp_http_client_config_t config = {nullptr}; + config.url = this->url_; + config.method = HTTP_METHOD_GET; + config.timeout_ms = (int) this->timeout_; + config.buffer_size = this->max_http_recv_buffer_; + config.auth_type = HTTP_AUTH_TYPE_BASIC; + config.max_authorization_retries = -1; +#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE + if (this->secure_()) { + config.crt_bundle_attach = esp_crt_bundle_attach; + } +#endif +#pragma GCC diagnostic pop + + this->client_ = esp_http_client_init(&config); + if ((this->status_ = esp_http_client_open(this->client_, 0)) != ESP_OK) { + return; + } + this->body_length_ = esp_http_client_fetch_headers(this->client_); + this->status_ = esp_http_client_get_status_code(this->client_); +} + +int OtaHttpIDF::http_read(uint8_t *buf, const size_t max_len) { + int bufsize = std::min(max_len, this->body_length_ - this->bytes_read_); + App.feed_wdt(); + int read_len = esp_http_client_read(this->client_, (char *) buf, bufsize); + if (read_len > 0) { + this->bytes_read_ += bufsize; + buf[bufsize] = '\0'; // not fed to ota + } + + return read_len; +} + +void OtaHttpIDF::http_end() { + esp_http_client_close(this->client_); + esp_http_client_cleanup(this->client_); +} + +} // namespace ota_http +} // namespace esphome + +#endif // USE_ESP_IDF diff --git a/esphome/components/ota_http/ota_http_idf.h b/esphome/components/ota_http/ota_http_idf.h new file mode 100644 index 0000000000..5997a2b666 --- /dev/null +++ b/esphome/components/ota_http/ota_http_idf.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ota_http.h" + +#ifdef USE_ESP_IDF + +#include "esp_http_client.h" + +namespace esphome { +namespace ota_http { + +class OtaHttpIDF : public OtaHttpComponent { + public: + void http_init() override; + int http_read(uint8_t *buf, size_t len) override; + void http_end() override; + + protected: + esp_http_client_handle_t client_{}; +}; + +} // namespace ota_http +} // namespace esphome + +#endif // USE_ESP_IDF diff --git a/esphome/components/ota_http/watchdog.cpp b/esphome/components/ota_http/watchdog.cpp new file mode 100644 index 0000000000..2d7ba0bf35 --- /dev/null +++ b/esphome/components/ota_http/watchdog.cpp @@ -0,0 +1,77 @@ +#include "watchdog.h" + +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +#include +#include +#ifdef USE_ESP32 +#include "esp_task_wdt.h" +#include "esp_idf_version.h" +#endif +#ifdef USE_RP2040 +#include "pico/stdlib.h" +#include "hardware/watchdog.h" +#endif +#ifdef USE_ESP8266 +#include "Esp.h" +#endif + +namespace esphome { +namespace ota_http { +namespace watchdog { + +uint32_t Watchdog::timeout_ms = 0; // NOLINT +uint32_t Watchdog::init_timeout_ms = Watchdog::get_timeout(); // NOLINT + +void Watchdog::set_timeout(uint32_t timeout_ms) { + ESP_LOGV(TAG, "set_timeout: %" PRId32 "ms", timeout_ms); +#ifdef USE_ESP8266 + EspClass::wdtEnable(timeout_ms); +#endif // USE_ESP8266 + +#ifdef USE_ESP32 +#if ESP_IDF_VERSION_MAJOR >= 5 + esp_task_wdt_config_t wdt_config = { + .timeout_ms = timeout_ms, + .idle_core_mask = 0x03, + .trigger_panic = true, + }; + esp_task_wdt_reconfigure(&wdt_config); +#else + esp_task_wdt_init(timeout_ms, true); +#endif // ESP_IDF_VERSION_MAJOR +#endif // USE_ESP32 + +#ifdef USE_RP2040 + watchdog_enable(timeout_ms, true); +#endif + Watchdog::timeout_ms = timeout_ms; +} + +uint32_t Watchdog::get_timeout() { + uint32_t timeout_ms = 0; + +#ifdef USE_ESP32 + timeout_ms = std::max((uint32_t) CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000, Watchdog::timeout_ms); +#endif // USE_ESP32 + +#ifdef USE_RP2040 + timeout_ms = watchdog_get_count() / 1000; +#endif + + if (timeout_ms == 0) { + // fallback to stored timeout + timeout_ms = Watchdog::timeout_ms; + } + ESP_LOGVV(TAG, "get_timeout: %" PRId32 "ms", timeout_ms); + + return timeout_ms; +} + +void Watchdog::reset() { Watchdog::set_timeout(Watchdog::init_timeout_ms); } + +} // namespace watchdog +} // namespace ota_http +} // namespace esphome diff --git a/esphome/components/ota_http/watchdog.h b/esphome/components/ota_http/watchdog.h new file mode 100644 index 0000000000..3bf89817e2 --- /dev/null +++ b/esphome/components/ota_http/watchdog.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace esphome { +namespace ota_http { +namespace watchdog { + +static const char *const TAG = "ota_http.watchdog"; + +class Watchdog { + public: + static uint32_t get_timeout(); + static void set_timeout(uint32_t timeout_ms); + static void reset(); + + private: + static uint32_t timeout_ms; // NOLINT + static uint32_t init_timeout_ms; // NOLINT + Watchdog() {} +}; + +} // namespace watchdog +} // namespace ota_http +} // namespace esphome diff --git a/tests/components/ota_http/test.esp32-c3-idf.yaml b/tests/components/ota_http/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..792ed87ad4 --- /dev/null +++ b/tests/components/ota_http/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +ota_http: + safe_mode: false diff --git a/tests/components/ota_http/test.esp32-c3.yaml b/tests/components/ota_http/test.esp32-c3.yaml new file mode 100644 index 0000000000..ad3e1b1c6c --- /dev/null +++ b/tests/components/ota_http/test.esp32-c3.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +ota_http: + exclude_certificate_bundle: true diff --git a/tests/components/ota_http/test.esp32-idf.yaml b/tests/components/ota_http/test.esp32-idf.yaml new file mode 100644 index 0000000000..0dfc346fe3 --- /dev/null +++ b/tests/components/ota_http/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +wifi: + ssid: MySSID + password: password1 + +ota_http: diff --git a/tests/components/ota_http/test.esp32.yaml b/tests/components/ota_http/test.esp32.yaml new file mode 100644 index 0000000000..ad3e1b1c6c --- /dev/null +++ b/tests/components/ota_http/test.esp32.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +ota_http: + exclude_certificate_bundle: true diff --git a/tests/components/ota_http/test.esp8266.yaml b/tests/components/ota_http/test.esp8266.yaml new file mode 100644 index 0000000000..c684e22e0d --- /dev/null +++ b/tests/components/ota_http/test.esp8266.yaml @@ -0,0 +1,10 @@ +wifi: + ssid: MySSID + password: password1 + +esp8266: + restore_from_flash: true + +ota_http: + exclude_certificate_bundle: true + safe_mode: true diff --git a/tests/components/ota_http/test.rp2040.yaml b/tests/components/ota_http/test.rp2040.yaml new file mode 100644 index 0000000000..ad3e1b1c6c --- /dev/null +++ b/tests/components/ota_http/test.rp2040.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +ota_http: + exclude_certificate_bundle: true