diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ee3b7f0c20d..f7793b1493c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -130,6 +130,7 @@ CONFIG_SCHEMA = cv.All( bk72xx=8892, ln882x=8820, rtl87xx=8892, + host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 843028fc974..f1857ed6642 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -68,7 +68,7 @@ void ESPHomeOTAComponent::setup() { return; } - err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); + err = this->server_->bind((struct sockaddr *) &server, sl); if (err != 0) { this->server_failed_(LOG_STR("bind")); return; @@ -133,12 +133,12 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select - // listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back - // to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more - // iteration so a connection queued mid-session is still caught here. + // Self-disable idle loop where a wake path re-enables on listener readiness + // (fast-select, raw-TCP accept_fn_). Host BSD select doesn't, so stay enabled. if (this->client_ == nullptr && !this->server_->ready()) { +#ifndef USE_HOST this->disable_loop(); +#endif return; } this->handle_handshake_(); diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 91239758848..9292cd77f61 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -1,20 +1,92 @@ #ifdef USE_HOST +#include "core.h" + #include "esphome/core/application.h" #include "preferences.h" +#include #include +#include +#include + +#ifdef __APPLE__ +#include +#endif + +#ifdef __linux__ +#include +#endif namespace { volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void signal_handler(int signal) { s_signal_received = signal; } + +char **s_argv = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::string *s_exe_path = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::string *s_reexec_path = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +std::string resolve_exe_path(const char *argv0) { +#ifdef __linux__ + char buf[PATH_MAX]; + ssize_t len = ::readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len > 0) { + buf[len] = '\0'; + return std::string(buf); + } +#endif +#ifdef __APPLE__ + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) { + char real[PATH_MAX]; + if (::realpath(buf, real) != nullptr) + return std::string(real); + return std::string(buf); + } +#endif + if (argv0 == nullptr) + return {}; + char real[PATH_MAX]; + if (::realpath(argv0, real) != nullptr) + return std::string(real); + return std::string(argv0); +} } // namespace +namespace esphome::host { + +char **get_argv() { return s_argv; } + +const std::string &get_exe_path() { + static const std::string empty; + return s_exe_path != nullptr ? *s_exe_path : empty; +} + +void arm_reexec(const std::string &path) { + if (s_reexec_path != nullptr) + *s_reexec_path = path; +} + +const char *get_reexec_path() { + if (s_reexec_path == nullptr || s_reexec_path->empty()) + return nullptr; + return s_reexec_path->c_str(); +} + +} // namespace esphome::host + // HAL functions live in hal.cpp. void setup(); void loop(); -int main() { +int main(int argc, char **argv) { + s_argv = argv; + static std::string exe_path = resolve_exe_path(argc > 0 ? argv[0] : nullptr); + s_exe_path = &exe_path; + static std::string reexec_path; + s_reexec_path = &reexec_path; + // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); diff --git a/esphome/components/host/core.h b/esphome/components/host/core.h new file mode 100644 index 00000000000..ab64119415b --- /dev/null +++ b/esphome/components/host/core.h @@ -0,0 +1,22 @@ +#pragma once +#ifdef USE_HOST + +#include + +namespace esphome::host { + +/// argv captured by main(); stable for process lifetime. +char **get_argv(); + +/// Absolute path to running exe (resolved at startup); empty on failure. +const std::string &get_exe_path(); + +/// Arm an execv on the next arch_restart(). Pass empty to disarm. +void arm_reexec(const std::string &path); + +/// Armed re-exec path, or nullptr. +const char *get_reexec_path(); + +} // namespace esphome::host + +#endif // USE_HOST diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index c7fef8d2e86..9108c1ea9d3 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -2,10 +2,14 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "core.h" #include +#include #include #include +#include // Empty host namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the @@ -50,7 +54,19 @@ void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { res = nanosleep(&ts, &ts); } while (res != 0 && errno == EINTR); } -void arch_restart() { exit(0); } +void arch_restart() { + // Host OTA: if a re-exec is armed, swap binaries instead of exiting. + if (const char *target = host::get_reexec_path()) { + char **argv = host::get_argv(); + if (argv != nullptr) { + execv(target, argv); + // execv only returns on failure. + ESP_LOGE("host", "execv('%s') failed: %s", target, std::strerror(errno)); + exit(1); + } + } + exit(0); +} uint32_t arch_get_cpu_cycle_count() { struct timespec spec; diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 579491fe1a3..83d8c611d5e 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,3 @@ -import logging - from esphome import automation import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform @@ -38,8 +36,6 @@ CONF_ON_PROGRESS = "on_progress" CONF_ON_STATE_CHANGE = "on_state_change" -_LOGGER = logging.getLogger(__name__) - ota_ns = cg.esphome_ns.namespace("ota") OTAComponent = ota_ns.class_("OTAComponent", cg.Component) OTAState = ota_ns.enum("OTAState") @@ -58,10 +54,6 @@ def _ota_final_validate(config): raise cv.Invalid( f"At least one platform must be specified for '{CONF_OTA}'; add '{CONF_PLATFORM}: {CONF_ESPHOME}' for original OTA functionality" ) - if CORE.is_host: - _LOGGER.warning( - "OTA not available for platform 'host'. OTA functionality disabled." - ) FINAL_VALIDATE_SCHEMA = _ota_final_validate @@ -172,5 +164,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "ota_backend_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index a2c9f2cc33a..ee503a49e16 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -1,26 +1,339 @@ #ifdef USE_HOST #include "ota_backend_host.h" -#include "esphome/core/defines.h" +#include "esphome/components/host/core.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +#include +#include +#include + +#ifdef __linux__ +#include +#include +#endif + +#ifdef __APPLE__ +#include +#endif namespace esphome::ota { -// Stub implementation - OTA is not supported on host platform. -// All methods return error codes to allow compilation of configs with OTA triggers. +namespace { + +const char *const TAG = "ota.host"; + +constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB +constexpr size_t HEADER_PEEK_SIZE = 64; + +ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { + int fd = ::open(path, O_RDONLY); + if (fd < 0) + return -1; + ssize_t got = ::read(fd, buf, len); + ::close(fd); + return got; +} + +#ifdef __linux__ +struct ElfIdent { + bool valid; + uint8_t ei_class; + uint8_t ei_data; + uint16_t e_machine; + uint16_t e_type; +}; + +ElfIdent parse_elf_(const uint8_t *buf, size_t len) { + ElfIdent out{}; + if (len < EI_NIDENT + 4) + return out; + if (buf[EI_MAG0] != ELFMAG0 || buf[EI_MAG1] != ELFMAG1 || buf[EI_MAG2] != ELFMAG2 || buf[EI_MAG3] != ELFMAG3) + return out; + out.ei_class = buf[EI_CLASS]; + out.ei_data = buf[EI_DATA]; + // e_type @ 16, e_machine @ 18, both in EI_DATA endianness. + uint16_t e_type; + uint16_t e_machine; + std::memcpy(&e_type, buf + 16, sizeof(e_type)); + std::memcpy(&e_machine, buf + 18, sizeof(e_machine)); + if (out.ei_data == ELFDATA2LSB) { + out.e_type = le16toh(e_type); + out.e_machine = le16toh(e_machine); + } else if (out.ei_data == ELFDATA2MSB) { + out.e_type = be16toh(e_type); + out.e_machine = be16toh(e_machine); + } else { + return out; + } + out.valid = true; + return out; +} + +bool validate_elf_(const char *staging_path, const std::string &exe_path) { + uint8_t new_buf[HEADER_PEEK_SIZE]; + uint8_t cur_buf[HEADER_PEEK_SIZE]; + ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { + ESP_LOGE(TAG, "ELF header read failed"); + return false; + } + ElfIdent new_id = parse_elf_(new_buf, new_n); + ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + if (!new_id.valid) { + ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); + return false; + } + if (!cur_id.valid) { + ESP_LOGE(TAG, "Could not parse running exe ELF header"); + return false; + } + if (new_id.ei_class != cur_id.ei_class) { + ESP_LOGE(TAG, "ELF class mismatch (uploaded=%u, running=%u)", new_id.ei_class, cur_id.ei_class); + return false; + } + if (new_id.ei_data != cur_id.ei_data) { + ESP_LOGE(TAG, "ELF endianness mismatch"); + return false; + } + if (new_id.e_machine != cur_id.e_machine) { + ESP_LOGE(TAG, "ELF e_machine mismatch (uploaded=0x%04x, running=0x%04x)", new_id.e_machine, cur_id.e_machine); + return false; + } + if (new_id.e_type != ET_EXEC && new_id.e_type != ET_DYN) { + ESP_LOGE(TAG, "ELF e_type=%u is not executable", new_id.e_type); + return false; + } + return true; +} +#endif // __linux__ + +#ifdef __APPLE__ +struct MachOIdent { + bool valid; + uint32_t cputype; + uint32_t cpusubtype; +}; + +MachOIdent parse_macho_(const uint8_t *buf, size_t len) { + MachOIdent out{}; + // mach_header is the common prefix of mach_header and mach_header_64; + // cputype/cpusubtype/filetype have identical offsets in both. + if (len < sizeof(struct mach_header)) + return out; + uint32_t magic; + std::memcpy(&magic, buf, sizeof(magic)); + bool swap; + if (magic == MH_MAGIC || magic == MH_MAGIC_64) { + swap = false; + } else if (magic == MH_CIGAM || magic == MH_CIGAM_64) { + swap = true; + } else { + return out; + } + struct mach_header hdr; + std::memcpy(&hdr, buf, sizeof(hdr)); + if (swap) { + hdr.cputype = OSSwapInt32(hdr.cputype); + hdr.cpusubtype = OSSwapInt32(hdr.cpusubtype); + hdr.filetype = OSSwapInt32(hdr.filetype); + } + if (hdr.filetype != MH_EXECUTE) + return out; + out.cputype = hdr.cputype; + out.cpusubtype = hdr.cpusubtype; + out.valid = true; + return out; +} + +bool validate_macho_(const char *staging_path, const std::string &exe_path) { + uint8_t new_buf[HEADER_PEEK_SIZE]; + uint8_t cur_buf[HEADER_PEEK_SIZE]; + ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + if (new_n < static_cast(sizeof(struct mach_header)) || + cur_n < static_cast(sizeof(struct mach_header))) { + ESP_LOGE(TAG, "Mach-O header read failed"); + return false; + } + MachOIdent new_id = parse_macho_(new_buf, new_n); + MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + if (!new_id.valid) { + ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); + return false; + } + if (!cur_id.valid) { + ESP_LOGE(TAG, "Could not parse running exe Mach-O header"); + return false; + } + if (new_id.cputype != cur_id.cputype || new_id.cpusubtype != cur_id.cpusubtype) { + ESP_LOGE(TAG, "Mach-O arch mismatch (uploaded=0x%x/0x%x, running=0x%x/0x%x)", new_id.cputype, new_id.cpusubtype, + cur_id.cputype, cur_id.cpusubtype); + return false; + } + return true; +} +#endif // __APPLE__ + +bool validate_executable_(const char *staging_path, const std::string &exe_path) { +#ifdef __linux__ + return validate_elf_(staging_path, exe_path); +#elif defined(__APPLE__) + return validate_macho_(staging_path, exe_path); +#else + (void) staging_path; + (void) exe_path; + ESP_LOGE(TAG, "Host OTA validation not implemented for this OS"); + return false; +#endif +} + +} // namespace std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { - return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + if (ota_type != OTA_TYPE_UPDATE_APP) + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. + if (image_size > MAX_OTA_SIZE) { + ESP_LOGE(TAG, "Refusing OTA of size %zu (exceeds %zu)", image_size, MAX_OTA_SIZE); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + + const std::string &exe = host::get_exe_path(); + if (exe.empty()) { + ESP_LOGE(TAG, "Could not resolve running executable path; cannot stage OTA"); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + this->final_path_ = exe; + this->staging_path_ = exe + ".ota.new"; + + // Clean up any leftover from a prior aborted OTA. + ::unlink(this->staging_path_.c_str()); + + this->fd_ = ::open(this->staging_path_.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0755); + if (this->fd_ < 0) { + ESP_LOGE(TAG, "Open '%s' failed: %s", this->staging_path_.c_str(), std::strerror(errno)); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + + this->expected_size_ = image_size; + this->bytes_written_ = 0; + this->md5_set_ = false; + this->md5_.init(); + + ESP_LOGD(TAG, "OTA begin: staging=%s, size=%zu", this->staging_path_.c_str(), image_size); + return OTA_RESPONSE_OK; } -void HostOTABackend::set_update_md5(const char *expected_md5) {} +void HostOTABackend::set_update_md5(const char *md5) { + if (parse_hex(md5, this->expected_md5_, 16)) + this->md5_set_ = true; +} -OTAResponseTypes HostOTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } +OTAResponseTypes HostOTABackend::write(uint8_t *data, size_t len) { + if (this->fd_ < 0) + return OTA_RESPONSE_ERROR_WRITING_FLASH; + size_t limit = this->expected_size_ != 0 ? this->expected_size_ : MAX_OTA_SIZE; + if (this->bytes_written_ + len > limit) { + ESP_LOGE(TAG, "Write past size limit (%zu)", limit); + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } -OTAResponseTypes HostOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } + size_t remaining = len; + const uint8_t *p = data; + while (remaining > 0) { + ssize_t n = ::write(this->fd_, p, remaining); + if (n < 0) { + if (errno == EINTR) + continue; + ESP_LOGE(TAG, "Write failed: %s", std::strerror(errno)); + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + p += n; + remaining -= n; + } + this->md5_.add(data, len); + this->bytes_written_ += len; + return OTA_RESPONSE_OK; +} -void HostOTABackend::abort() {} +OTAResponseTypes HostOTABackend::end() { + if (this->fd_ < 0) + return OTA_RESPONSE_ERROR_UPDATE_END; + + if (this->bytes_written_ == 0) { + ESP_LOGE(TAG, "OTA ended with no data written"); + this->abort(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + if (this->expected_size_ != 0 && this->bytes_written_ != this->expected_size_) { + ESP_LOGE(TAG, "Size mismatch: got %zu, expected %zu", this->bytes_written_, this->expected_size_); + this->abort(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + if (this->md5_set_) { + this->md5_.calculate(); + if (!this->md5_.equals_bytes(this->expected_md5_)) { + ESP_LOGE(TAG, "MD5 mismatch"); + this->abort(); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } + } + + if (::fsync(this->fd_) != 0) { + ESP_LOGW(TAG, "fsync failed: %s", std::strerror(errno)); + } + ::close(this->fd_); + this->fd_ = -1; + + if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + if (::chmod(this->staging_path_.c_str(), 0755) != 0) { + ESP_LOGW(TAG, "chmod failed: %s", std::strerror(errno)); + } + + if (::rename(this->staging_path_.c_str(), this->final_path_.c_str()) != 0) { + ESP_LOGE(TAG, "rename '%s' -> '%s' failed: %s", this->staging_path_.c_str(), this->final_path_.c_str(), + std::strerror(errno)); + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + // arch_restart() (via App::safe_reboot) will execv this path with the original argv. + host::arm_reexec(this->final_path_); + this->staging_path_.clear(); + ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + return OTA_RESPONSE_OK; +} + +void HostOTABackend::abort() { + if (this->fd_ >= 0) { + ::close(this->fd_); + this->fd_ = -1; + } + if (!this->staging_path_.empty()) { + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + } + this->expected_size_ = 0; + this->bytes_written_ = 0; + this->md5_set_ = false; +} } // namespace esphome::ota #endif diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 4451fdfe182..51ffdaeda3e 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -2,11 +2,16 @@ #ifdef USE_HOST #include "ota_backend.h" +#include "esphome/components/md5/md5.h" + +#include +#include +#include + namespace esphome::ota { -/// Stub OTA backend for host platform - allows compilation but does not implement OTA. -/// All operations return error codes immediately. This enables configurations with -/// OTA triggers to compile for host platform during development. +/// Host OTA backend: stages new binary to `.ota.new`, validates ELF/Mach-O +/// matches the running arch, renames over ``, and arms execv via arch_restart(). class HostOTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); @@ -15,6 +20,16 @@ class HostOTABackend final { OTAResponseTypes end(); void abort(); bool supports_compression() { return false; } + + protected: + md5::MD5Digest md5_{}; + std::string staging_path_; + std::string final_path_; + size_t expected_size_{0}; + size_t bytes_written_{0}; + uint8_t expected_md5_[16]{}; + int fd_{-1}; + bool md5_set_{false}; }; std::unique_ptr make_ota_backend(); diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 8e9968e05c0..ee22e4b97b2 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,7 +14,15 @@ namespace esphome::socket { BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - if (!monitor_loop || this->fd_ < 0) + if (this->fd_ < 0) + return; +#ifdef USE_HOST + // Release listening ports on OTA re-exec. + int flags = ::fcntl(this->fd_, F_GETFD, 0); + if (flags >= 0) + ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); +#endif + if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index ef0eddc603a..e13d5668afc 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -782,6 +782,9 @@ class EsphomeCore: return self.relative_build_path("build", f"{self.name}.bin") if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") + if self.is_host: + # Host builds produce a native ELF/Mach-O named `program`. + return self.relative_pioenvs_path(self.name, "program") return self.relative_pioenvs_path(self.name, "firmware.bin") @property diff --git a/tests/integration/fixtures/host_ota_rejects_garbage.yaml b/tests/integration/fixtures/host_ota_rejects_garbage.yaml new file mode 100644 index 00000000000..ebf7977123d --- /dev/null +++ b/tests/integration/fixtures/host_ota_rejects_garbage.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_self_update.yaml b/tests/integration/fixtures/host_ota_self_update.yaml new file mode 100644 index 00000000000..ebf7977123d --- /dev/null +++ b/tests/integration/fixtures/host_ota_self_update.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py new file mode 100644 index 00000000000..e1036fdf1cf --- /dev/null +++ b/tests/integration/test_host_ota.py @@ -0,0 +1,152 @@ +"""End-to-end OTA tests on the host platform. + +Exercises the native OTA protocol against a real host binary, then asserts +pid is preserved across the post-OTA execv. A second OTA on the post-exec +instance covers the FD_CLOEXEC path. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from contextlib import contextmanager +import socket + +import pytest + +from esphome import espota2 + +from .conftest import run_binary, wait_and_connect_api_client +from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-ota-test" + + +@contextmanager +def _reserve_port() -> Generator[tuple[int, socket.socket]]: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 0)) + try: + yield s.getsockname()[1], s + finally: + s.close() + + +async def _wait_for_port(host: str, port: int, timeout: float) -> None: + """Poll until a TCP port accepts connections, or raise TimeoutError.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + _, writer = await asyncio.open_connection(host, port) + except (ConnectionRefusedError, OSError): + await asyncio.sleep(PORT_POLL_INTERVAL) + continue + writer.close() + await writer.wait_closed() + return + raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s") + + +@pytest.mark.asyncio +async def test_host_ota_self_update( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Self-OTA: upload the running binary back to itself, expect re-exec.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + api_socket.close() + ota_socket.close() + + loop = asyncio.get_running_loop() + ota_staged = loop.create_future() + rebooted = loop.create_future() + + def on_log(line: str) -> None: + if not ota_staged.done() and "OTA staged at" in line: + ota_staged.set_result(True) + if not rebooted.done() and "Rebooting safely" in line: + rebooted.set_result(True) + + async with run_binary(binary_path, line_callback=on_log) as (proc, _lines): + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + async with wait_and_connect_api_client(port=api_port) as client: + info_before = await client.device_info() + assert info_before.name == DEVICE_NAME + + # espota2 is blocking; run in executor. + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + ) + assert rc == 0, "espota2 reported failure" + + await asyncio.wait_for(ota_staged, timeout=10.0) + await asyncio.wait_for(rebooted, timeout=10.0) + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + + # execv preserves pid; mismatch means external respawn. + assert proc.returncode is None, "process exited instead of execing" + assert proc.pid == pid_before + + async with wait_and_connect_api_client(port=api_port) as client: + info_after = await client.device_info() + assert info_after.name == DEVICE_NAME + assert info_after.name == info_before.name + + # Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind). + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + ) + assert rc == 0, "second OTA failed -- listener leaked across execv" + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + assert proc.pid == pid_before + + +@pytest.mark.asyncio +async def test_host_ota_rejects_garbage( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + integration_test_dir, +) -> None: + """Bogus payload is rejected and the device keeps running.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # 192 bytes that are neither ELF nor Mach-O. + bogus_path = integration_test_dir / "bogus.bin" + bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) + + api_socket.close() + ota_socket.close() + + async with run_binary(binary_path) as (proc, _lines): + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + + loop = asyncio.get_running_loop() + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path + ) + assert rc == 1 + + await asyncio.sleep(0.5) + assert proc.returncode is None, "process died on rejected OTA" + assert proc.pid == pid_before + + async with wait_and_connect_api_client(port=api_port) as client: + info = await client.device_info() + assert info.name == DEVICE_NAME diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 1a52e6b29ed..2322fdd014f 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,30 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_firmware_bin__default(self, target): + """Default platforms produce //firmware.bin.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.firmware_bin == Path( + "foo/build/.pioenvs/test-device/firmware.bin" + ) + + def test_firmware_bin__libretiny(self, target): + """The libretiny platform produces firmware.uf2.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"} + assert target.firmware_bin == Path( + "foo/build/.pioenvs/test-device/firmware.uf2" + ) + + def test_firmware_bin__host(self, target): + """Host platform produces a native ELF/Mach-O named `program`, + not firmware.bin -- needed for `esphome upload` to find the + right artifact for the host OTA backend.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "host"} + assert target.firmware_bin == Path("foo/build/.pioenvs/test-device/program") + @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") def test_data_dir_default_unix(self, target): """Test data_dir returns .esphome in config directory by default on Unix."""