[ota] Implement host platform OTA backend with re-exec for integration testing (#16304)

This commit is contained in:
J. Nick Koston
2026-05-11 10:51:08 -05:00
committed by GitHub
parent 4ac7bc4606
commit a52ca4f80a
14 changed files with 664 additions and 27 deletions
@@ -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,
@@ -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_();
+73 -1
View File
@@ -1,20 +1,92 @@
#ifdef USE_HOST
#include "core.h"
#include "esphome/core/application.h"
#include "preferences.h"
#include <climits>
#include <csignal>
#include <cstdlib>
#include <string>
#ifdef __APPLE__
#include <mach-o/dyld.h>
#endif
#ifdef __linux__
#include <unistd.h>
#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);
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#ifdef USE_HOST
#include <string>
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
+17 -1
View File
@@ -2,10 +2,14 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "core.h"
#include <time.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
#include <cstring>
// 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;
+1 -8
View File
@@ -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},
}
)
+321 -8
View File
@@ -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 <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#ifdef __linux__
#include <elf.h>
#include <endian.h>
#endif
#ifdef __APPLE__
#include <mach-o/loader.h>
#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<ssize_t>(EI_NIDENT + 4) || cur_n < static_cast<ssize_t>(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<ssize_t>(sizeof(struct mach_header)) ||
cur_n < static_cast<ssize_t>(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<HostOTABackend> make_ota_backend() { return make_unique<HostOTABackend>(); }
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
+18 -3
View File
@@ -2,11 +2,16 @@
#ifdef USE_HOST
#include "ota_backend.h"
#include "esphome/components/md5/md5.h"
#include <cstddef>
#include <cstdint>
#include <string>
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 `<exe>.ota.new`, validates ELF/Mach-O
/// matches the running arch, renames over `<exe>`, 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<HostOTABackend> make_ota_backend();
@@ -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_);
+3
View File
@@ -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
@@ -0,0 +1,9 @@
esphome:
name: host-ota-test
host:
api:
ota:
- platform: esphome
port: __OTA_PORT__
logger:
level: DEBUG
@@ -0,0 +1,9 @@
esphome:
name: host-ota-test
host:
api:
ota:
- platform: esphome
port: __OTA_PORT__
logger:
level: DEBUG
+152
View File
@@ -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
+24
View File
@@ -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 <pioenvs>/<name>/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."""