mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3407cfdd | ||
|
|
d3e27054f6 | ||
|
|
bd58b5c8b3 | ||
|
|
c1a326f32e | ||
|
|
a14ea0e8fa | ||
|
|
48d6368ff9 | ||
|
|
83cff59fdd | ||
|
|
89489b1f0d | ||
|
|
7569a7b5ce |
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.9.0-dev
|
||||
PROJECT_NUMBER = 2026.8.0b2
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The handler lives for the life of the process; WebServerBase never destroys its server
|
||||
// AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed
|
||||
base->add_handler(new OTARequestHandler(this)); // NOLINT
|
||||
}
|
||||
|
||||
|
||||
@@ -112,18 +112,9 @@ class AuthMiddlewareHandler : public MiddlewareHandler {
|
||||
|
||||
class WebServerBase final {
|
||||
public:
|
||||
// The AsyncWebServer is created once and intentionally never deleted: on Arduino
|
||||
// platforms ESPAsyncWebServer owns its registered handlers, so destroying it would
|
||||
// also destroy live components (e.g. the captive portal) out from under us.
|
||||
// init()/deinit() refcount users and start/stop the listener; handlers are
|
||||
// registered once at creation and survive listener restarts.
|
||||
void init() {
|
||||
this->initialized_++;
|
||||
if (this->server_ != nullptr) {
|
||||
if (this->initialized_ == 1) {
|
||||
// Restart the listener after a previous deinit()
|
||||
this->server_->begin();
|
||||
}
|
||||
if (this->initialized_) {
|
||||
this->initialized_++;
|
||||
return;
|
||||
}
|
||||
this->server_ = new AsyncWebServer(this->port_);
|
||||
@@ -135,13 +126,14 @@ class WebServerBase final {
|
||||
|
||||
for (auto *handler : this->handlers_)
|
||||
this->server_->addHandler(handler);
|
||||
|
||||
this->initialized_++;
|
||||
}
|
||||
void deinit() {
|
||||
if (this->initialized_ == 0)
|
||||
return; // unbalanced deinit()
|
||||
this->initialized_--;
|
||||
if (this->initialized_ == 0) {
|
||||
this->server_->end();
|
||||
delete this->server_;
|
||||
this->server_ = nullptr;
|
||||
}
|
||||
}
|
||||
AsyncWebServer *get_server() const { return this->server_; }
|
||||
|
||||
@@ -136,21 +136,10 @@ bool WiFiComponent::wifi_apply_power_save_() {
|
||||
https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251
|
||||
*/
|
||||
#undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr()
|
||||
#undef netif_set_down // need to call lwIP-v1.4 netif_set_down()
|
||||
extern "C" {
|
||||
struct netif *eagle_lwip_getif(int netif_index);
|
||||
void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw);
|
||||
void netif_set_down(struct netif *netif);
|
||||
};
|
||||
|
||||
// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP
|
||||
// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in
|
||||
// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308).
|
||||
static void sta_netif_down() {
|
||||
struct netif *iface = eagle_lwip_getif(STATION_IF);
|
||||
if (iface != nullptr)
|
||||
netif_set_down(iface);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
|
||||
@@ -534,9 +523,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
|
||||
global_wifi_component->sta_state_ = static_cast<uint8_t>(ESP8266WiFiSTAState::ERROR_FAILED);
|
||||
}
|
||||
global_wifi_component->error_from_callback_ = true;
|
||||
#if LWIP_VERSION_MAJOR != 1
|
||||
sta_netif_down();
|
||||
#endif
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
global_wifi_component->pending_.disconnect = true;
|
||||
#endif
|
||||
@@ -550,9 +536,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
|
||||
// https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors
|
||||
if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) {
|
||||
ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting");
|
||||
#if LWIP_VERSION_MAJOR != 1
|
||||
sta_netif_down();
|
||||
#endif
|
||||
wifi_station_disconnect();
|
||||
global_wifi_component->error_from_callback_ = true;
|
||||
}
|
||||
@@ -736,12 +719,8 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
bool WiFiComponent::wifi_disconnect_() {
|
||||
bool ret = true;
|
||||
// Only call disconnect if interface is up
|
||||
if (wifi_get_opmode() & WIFI_STA) {
|
||||
#if LWIP_VERSION_MAJOR != 1
|
||||
sta_netif_down();
|
||||
#endif
|
||||
if (wifi_get_opmode() & WIFI_STA)
|
||||
ret = wifi_station_disconnect();
|
||||
}
|
||||
station_config conf{};
|
||||
memset(&conf, 0, sizeof(conf));
|
||||
ETS_UART_INTR_DISABLE();
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.9.0-dev"
|
||||
__version__ = "2026.8.0b2"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
+30
-128
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import contextlib
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
@@ -9,6 +8,7 @@ import logging
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -76,14 +76,6 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset(
|
||||
UPLOAD_BLOCK_SIZE = 8192
|
||||
UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
|
||||
|
||||
# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time
|
||||
# to clean up a half-open connection (its handshake watchdog runs at 20s) before it
|
||||
# accepts a new one, so wait between attempts instead of failing the upload outright.
|
||||
# Every resolved address is tried once, and this many extra attempts are shared
|
||||
# across the addresses on top of that.
|
||||
EXTRA_UPLOAD_ATTEMPTS = 2
|
||||
UPLOAD_RETRY_DELAY = 5.0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Authentication method lookup table: response -> (hash_func, nonce_size, name)
|
||||
@@ -179,23 +171,6 @@ class OTAError(EsphomeError):
|
||||
pass
|
||||
|
||||
|
||||
class OTANetworkError(OTAError):
|
||||
"""Network-level OTA failure (timeout, reset, closed connection); retrying may succeed."""
|
||||
|
||||
|
||||
def _committed_error(err: OTANetworkError) -> OTAError:
|
||||
"""Wrap a network failure that happened once the device had the full image.
|
||||
|
||||
Past that point the device commits and reboots on its own, so the failure
|
||||
must not be retried; a re-upload could flash a device that already updated.
|
||||
"""
|
||||
return OTAError(
|
||||
f"{err} (the device may have already committed the update and "
|
||||
f"be rebooting; check whether it comes back with the new "
|
||||
f"firmware before uploading again)"
|
||||
)
|
||||
|
||||
|
||||
def recv_decode(
|
||||
sock: socket.socket, amount: int, decode: bool = True
|
||||
) -> bytes | list[int]:
|
||||
@@ -234,22 +209,19 @@ def receive_exactly(
|
||||
try:
|
||||
data += recv_decode(sock, 1, decode=decode) # type: ignore[operator]
|
||||
except OSError as err:
|
||||
raise OTANetworkError(f"receiving {msg} response: {err}") from err
|
||||
raise OTAError(f"receiving {msg} response: {err}") from err
|
||||
|
||||
try:
|
||||
check_error(data, expect)
|
||||
except OTAError as err:
|
||||
sock.close()
|
||||
# type(err) preserves OTANetworkError vs OTAError so callers can tell
|
||||
# retryable network failures from device-reported errors; subclasses
|
||||
# must accept a single message argument
|
||||
raise type(err)(f"receiving {msg}: {err}") from err
|
||||
raise OTAError(f"receiving {msg}: {err}") from err
|
||||
|
||||
while len(data) < amount:
|
||||
try:
|
||||
data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
|
||||
except OSError as err:
|
||||
raise OTANetworkError(f"receiving {msg}: {err}") from err
|
||||
raise OTAError(f"receiving {msg}: {err}") from err
|
||||
return data
|
||||
|
||||
|
||||
@@ -265,7 +237,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None
|
||||
# accept-any-response reads (e.g. feature negotiation, auth nonces) would be
|
||||
# silently passed through and surface later as cryptic decode/timeout failures.
|
||||
if not data:
|
||||
raise OTANetworkError(
|
||||
raise OTAError(
|
||||
"Device closed connection without responding. "
|
||||
"This may indicate the device ran out of memory, "
|
||||
"a network issue, or the connection was interrupted."
|
||||
@@ -302,7 +274,7 @@ def send_check(
|
||||
|
||||
sock.sendall(data)
|
||||
except OSError as err:
|
||||
raise OTANetworkError(f"sending {msg}: {err}") from err
|
||||
raise OTAError(f"sending {msg}: {err}") from err
|
||||
|
||||
|
||||
def perform_ota(
|
||||
@@ -334,7 +306,7 @@ def perform_ota(
|
||||
send_check(sock, MAGIC_BYTES, "magic bytes")
|
||||
|
||||
_, version = receive_exactly(sock, 2, "version", RESPONSE_OK)
|
||||
_LOGGER.info("Connection established; device supports OTA version %s", version)
|
||||
_LOGGER.debug("Device support OTA version: %s", version)
|
||||
supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0)
|
||||
if version not in supported_versions:
|
||||
raise OTAError(
|
||||
@@ -445,8 +417,6 @@ def perform_ota(
|
||||
hash_func, nonce_size, hash_name = _AUTH_METHODS[auth]
|
||||
perform_auth(sock, password, hash_func, nonce_size, hash_name)
|
||||
|
||||
_LOGGER.info("Handshake complete")
|
||||
|
||||
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
|
||||
sock.settimeout(90.0)
|
||||
|
||||
@@ -479,43 +449,21 @@ def perform_ota(
|
||||
|
||||
offset = 0
|
||||
progress = ProgressBar("Uploading")
|
||||
try:
|
||||
while True:
|
||||
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
|
||||
if not chunk:
|
||||
break
|
||||
offset += len(chunk)
|
||||
|
||||
try:
|
||||
sock.sendall(chunk)
|
||||
except OSError as err:
|
||||
# A send failure can hide an error byte the device reported
|
||||
# just before dropping the connection; surface that as the
|
||||
# real, non-retryable cause when it is available
|
||||
try:
|
||||
sock.settimeout(1.0)
|
||||
check_error(recv_decode(sock, 1), None)
|
||||
except (OSError, OTANetworkError) as probe_err:
|
||||
_LOGGER.debug(
|
||||
"No device error behind the send failure: %s", probe_err
|
||||
)
|
||||
raise OTANetworkError(f"sending data: {err}") from err
|
||||
while True:
|
||||
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
|
||||
if not chunk:
|
||||
break
|
||||
offset += len(chunk)
|
||||
|
||||
try:
|
||||
sock.sendall(chunk)
|
||||
if version >= OTA_VERSION_2_0:
|
||||
try:
|
||||
receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
|
||||
except OTANetworkError as err:
|
||||
if offset < upload_size:
|
||||
raise
|
||||
# The device already had the complete image when this ack
|
||||
# was lost, so it may be committing; do not retry
|
||||
raise _committed_error(err) from err
|
||||
receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
|
||||
except OSError as err:
|
||||
sys.stderr.write("\n")
|
||||
raise OTAError(f"sending data: {err}") from err
|
||||
|
||||
progress.update(offset / upload_size)
|
||||
except OTAError:
|
||||
# Terminate the progress bar line before the error is logged
|
||||
progress.done()
|
||||
raise
|
||||
progress.update(offset / upload_size)
|
||||
progress.done()
|
||||
|
||||
# Enable nodelay for last checks
|
||||
@@ -524,25 +472,11 @@ def perform_ota(
|
||||
|
||||
_LOGGER.info("Upload took %.2f seconds, waiting for result...", duration)
|
||||
|
||||
# Once the device has the complete image it commits the update and
|
||||
# reboots on its own; the exact commit point is not observable from
|
||||
# here, so treat everything past the data phase as non-retryable. A
|
||||
# re-upload could flash a device that already updated successfully.
|
||||
try:
|
||||
receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
|
||||
receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
|
||||
except OTANetworkError as err:
|
||||
raise _committed_error(err) from err
|
||||
receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
|
||||
receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
|
||||
send_check(sock, RESPONSE_OK, "end acknowledgement")
|
||||
|
||||
try:
|
||||
send_check(sock, RESPONSE_OK, "end acknowledgement")
|
||||
except OTANetworkError as err:
|
||||
# The device treats a missing end acknowledgement as non-fatal and is
|
||||
# already rebooting into the new firmware, so the update succeeded
|
||||
_LOGGER.warning("Failed sending end acknowledgement: %s", err)
|
||||
_LOGGER.info("OTA successful (end acknowledgement not delivered)")
|
||||
else:
|
||||
_LOGGER.info("OTA successful")
|
||||
_LOGGER.info("OTA successful")
|
||||
|
||||
# Do not connect logs until it is fully on
|
||||
time.sleep(1)
|
||||
@@ -576,33 +510,8 @@ def run_ota_impl_(
|
||||
)
|
||||
raise OTAError(err) from err
|
||||
|
||||
if not res:
|
||||
_LOGGER.error("No addresses to connect to for %s", remote_host)
|
||||
return 1, None
|
||||
|
||||
# Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries
|
||||
# are shared across the addresses, cycling through them. Wait before an
|
||||
# attempt when the previous one actually reached the device, or when
|
||||
# revisiting an address, so a flaky link can recover and the device can
|
||||
# clean up a half-open connection (its handshake watchdog runs at 20s);
|
||||
# moving on to the next address family stays immediate. Known limitation:
|
||||
# a silent mid-transfer drop with no reset can wedge the device until its
|
||||
# 90s data timeout, which outlasts this budget; the retries target the
|
||||
# common failures where the device resets or closes the link promptly.
|
||||
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
||||
last_error = ""
|
||||
reached_device = False
|
||||
for attempt in range(total_attempts):
|
||||
af, socktype, _, _, sa = res[attempt % len(res)]
|
||||
if reached_device or attempt >= len(res):
|
||||
_LOGGER.info(
|
||||
"Retrying in %.0f seconds (attempt %d of %d)...",
|
||||
UPLOAD_RETRY_DELAY,
|
||||
attempt + 1,
|
||||
total_attempts,
|
||||
)
|
||||
time.sleep(UPLOAD_RETRY_DELAY)
|
||||
reached_device = False
|
||||
for r in res:
|
||||
af, socktype, _, _, sa = r
|
||||
_LOGGER.info("Connecting to %s port %s...", sa[0], sa[1])
|
||||
sock = socket.socket(af, socktype)
|
||||
sock.settimeout(20.0)
|
||||
@@ -610,30 +519,23 @@ def run_ota_impl_(
|
||||
sock.connect(sa)
|
||||
except OSError as err:
|
||||
sock.close()
|
||||
_LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
|
||||
last_error = f"connecting to {sa[0]} failed: {err}"
|
||||
_LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
|
||||
continue
|
||||
|
||||
_LOGGER.info("Connected to %s", sa[0])
|
||||
reached_device = True
|
||||
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
|
||||
with Path(filename).open("rb") as file_handle:
|
||||
try:
|
||||
perform_ota(sock, password, file_handle, filename, ota_type)
|
||||
except OTANetworkError as err:
|
||||
# Transient network failure; retry
|
||||
last_error = str(err)
|
||||
_LOGGER.warning("%s", last_error)
|
||||
continue
|
||||
except OTAError as err:
|
||||
# Device-reported error (wrong password, wrong flash size, ...);
|
||||
# retrying cannot succeed, so fail immediately
|
||||
_LOGGER.error(str(err))
|
||||
return 1, None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
# Successfully uploaded to sa[0]
|
||||
return 0, sa[0]
|
||||
|
||||
_LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error)
|
||||
_LOGGER.error("Connection failed.")
|
||||
return 1, None
|
||||
|
||||
|
||||
|
||||
@@ -44,17 +44,13 @@ def mock_file() -> io.BytesIO:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sleep() -> Generator[Mock]:
|
||||
"""Mock time.sleep so delays don't slow down tests."""
|
||||
with patch("time.sleep") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_time(mock_sleep: Mock) -> Generator[None]:
|
||||
def mock_time() -> Generator[None]:
|
||||
"""Mock time-related functions for consistent testing."""
|
||||
# Provide enough values for multiple calls (tests may call perform_ota multiple times)
|
||||
with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]):
|
||||
with (
|
||||
patch("time.sleep"),
|
||||
patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@@ -83,28 +79,6 @@ def mock_resolve_ip() -> Generator[Mock]:
|
||||
yield mock
|
||||
|
||||
|
||||
DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0)
|
||||
DUAL_STACK_SA4 = ("192.168.1.100", 3232)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock:
|
||||
"""Make resolve_ip_address return an IPv6 and an IPv4 address."""
|
||||
mock_resolve_ip.return_value = [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4),
|
||||
]
|
||||
return mock_resolve_ip
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firmware_file(tmp_path: Path) -> Path:
|
||||
"""Create a firmware file on disk for run_ota_impl_ tests."""
|
||||
firmware = tmp_path / "firmware.bin"
|
||||
firmware.write_bytes(b"firmware content")
|
||||
return firmware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_perform_ota() -> Generator[Mock]:
|
||||
"""Mock perform_ota function for testing."""
|
||||
@@ -163,11 +137,9 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="receiving auth:.*Authentication invalid"
|
||||
) as exc_info:
|
||||
):
|
||||
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
|
||||
|
||||
# Device-reported errors must stay plain OTAError, not the retryable kind
|
||||
assert not isinstance(exc_info.value, espota2.OTANetworkError)
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -175,30 +147,10 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors."""
|
||||
mock_socket.recv.side_effect = OSError("Connection reset")
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving test response"):
|
||||
with pytest.raises(espota2.OTAError, match="receiving test response"):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors after the first byte."""
|
||||
mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving test:"):
|
||||
espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly raises OTANetworkError when the device closes the connection."""
|
||||
mock_socket.recv.return_value = b""
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_msg"),
|
||||
[
|
||||
@@ -275,15 +227,15 @@ def test_check_error_unexpected_response() -> None:
|
||||
|
||||
|
||||
def test_check_error_empty_data() -> None:
|
||||
"""Test check_error raises the retryable OTANetworkError when the device closes the connection."""
|
||||
"""Test check_error raises error when device closes connection without responding."""
|
||||
with pytest.raises(
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
espota2.OTAError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.check_error([], [espota2.RESPONSE_OK])
|
||||
|
||||
# Also test with empty bytes
|
||||
with pytest.raises(
|
||||
espota2.OTANetworkError, match="Device closed connection without responding"
|
||||
espota2.OTAError, match="Device closed connection without responding"
|
||||
):
|
||||
espota2.check_error(b"", [espota2.RESPONSE_OK])
|
||||
|
||||
@@ -578,144 +530,6 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
def _no_auth_handshake(version: int) -> list[bytes]:
|
||||
"""Recv responses for a handshake without auth, up to the MD5 check."""
|
||||
return [
|
||||
bytes([espota2.RESPONSE_OK]), # First byte of version response
|
||||
bytes([version]), # Version number
|
||||
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
|
||||
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
|
||||
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
|
||||
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None:
|
||||
"""Test OTA raises the retryable OTANetworkError when sending a chunk fails."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Probe for a pending error byte fails too
|
||||
]
|
||||
# Sends before the data phase: magic bytes, features, binary size, MD5;
|
||||
# fail on the fifth sendall, the first firmware chunk
|
||||
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="sending data:"):
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_chunk_send_error_surfaces_device_error(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a device error byte pending behind a send failure becomes the cause."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed
|
||||
]
|
||||
mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")]
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Writing OTA data to flash memory failed"
|
||||
) as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device-reported error is not retryable
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_final_chunk_ack_failure_not_retryable(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a lost ack for the final chunk is not retried."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Ack for the only (final) chunk is lost
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device already had the whole image, so it may be committing
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_intermediate_chunk_ack_failure_retryable(
|
||||
mock_socket: Mock,
|
||||
) -> None:
|
||||
"""Test a lost ack for a non-final chunk stays retryable."""
|
||||
# Two chunks: the firmware is larger than one upload block
|
||||
big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1))
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_2_0),
|
||||
OSError("Connection reset"), # Ack for the first of two chunks is lost
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"):
|
||||
espota2.perform_ota(mock_socket, None, big_file, "test.bin")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_post_commit_failure_not_retryable(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a network failure after the device committed is a plain OTAError."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
OSError("Connection reset"), # Connection lost waiting for end result
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="receiving update end result") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# Must not be the retryable kind; the device is already rebooting
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_md5_mismatch_not_marked_committed(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test an MD5 mismatch keeps its own message and stays non-retryable."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update
|
||||
]
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc:
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
# The device aborted without committing, so the message must not claim
|
||||
# the update may have been installed, and the error must not be retried
|
||||
assert not isinstance(exc.value, espota2.OTANetworkError)
|
||||
assert "committed" not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_end_ack_send_failure_is_success(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Test a send failure on the final acknowledgement does not fail the OTA."""
|
||||
mock_socket.recv.side_effect = [
|
||||
*_no_auth_handshake(espota2.OTA_VERSION_1_0),
|
||||
bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything
|
||||
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed
|
||||
]
|
||||
# Sends: magic bytes, features, binary size, MD5, one firmware chunk;
|
||||
# fail on the sixth sendall, the end acknowledgement
|
||||
mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")]
|
||||
|
||||
# Must not raise; the device treats a missing acknowledgement as non-fatal
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
assert mock_socket.sendall.call_count == 6
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_successful(
|
||||
mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock
|
||||
@@ -750,70 +564,13 @@ def test_run_ota_impl_successful(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_connection_failed(
|
||||
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ retries when connection fails and eventually gives up."""
|
||||
def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None:
|
||||
"""Test run_ota_impl_ when connection fails."""
|
||||
mock_socket.connect.side_effect = OSError("Connection refused")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
# A single address gets the whole attempt budget, with a delay before
|
||||
# each revisit
|
||||
assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
|
||||
mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_connect_retry_succeeds(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ succeeds when a retry connects after a failed attempt."""
|
||||
mock_socket.connect.side_effect = [OSError("Connection timed out"), None]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
assert mock_socket.connect.call_count == 2
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
mock_perform_ota.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_network_error_retry_succeeds(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ retries after a network error during the upload."""
|
||||
mock_perform_ota.side_effect = [
|
||||
espota2.OTANetworkError("receiving features: Device closed connection"),
|
||||
None,
|
||||
]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
assert mock_perform_ota.call_count == 2
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_network_error_exhausts_attempts(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ gives up after all attempts hit network errors."""
|
||||
mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe")
|
||||
# Create a real firmware file
|
||||
firmware_file = tmp_path / "firmware.bin"
|
||||
firmware_file.write_bytes(b"firmware content")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
@@ -821,112 +578,7 @@ def test_run_ota_impl_network_error_exhausts_attempts(
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
|
||||
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_multiple_addresses_cycle(
|
||||
mock_socket: Mock, firmware_file: Path, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ visits every address and cycles for the retries."""
|
||||
mock_socket.connect.side_effect = OSError("No route to host")
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
# Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare
|
||||
# attempts cycle back through them; the budget is shared, not per address
|
||||
assert mock_socket.connect.call_args_list == [
|
||||
call(DUAL_STACK_SA6),
|
||||
call(DUAL_STACK_SA4),
|
||||
call(DUAL_STACK_SA6),
|
||||
call(DUAL_STACK_SA4),
|
||||
]
|
||||
# No connect ever reached the device, so the delay only applies before
|
||||
# the revisits
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_second_address_succeeds_without_delay(
|
||||
mock_socket: Mock,
|
||||
firmware_file: Path,
|
||||
mock_perform_ota: Mock,
|
||||
mock_sleep: Mock,
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ falls through to the next address with no pause."""
|
||||
mock_socket.connect.side_effect = [OSError("No route to host"), None]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
mock_sleep.assert_not_called()
|
||||
mock_perform_ota.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
|
||||
def test_run_ota_impl_pauses_after_reaching_device(
|
||||
mock_socket: Mock,
|
||||
firmware_file: Path,
|
||||
mock_perform_ota: Mock,
|
||||
mock_sleep: Mock,
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ pauses before the next address once the device was reached."""
|
||||
mock_perform_ota.side_effect = [
|
||||
espota2.OTANetworkError("sending data: connection reset"),
|
||||
None,
|
||||
]
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 0
|
||||
assert result_host == "192.168.1.100"
|
||||
# The first attempt reached the device, so the next one waits first even
|
||||
# though it targets a fresh address
|
||||
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
|
||||
def test_run_ota_impl_device_error_not_retried(
|
||||
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ fails immediately on a device-reported error."""
|
||||
mock_perform_ota.side_effect = espota2.OTAError(
|
||||
"Authentication invalid. Is the password correct?"
|
||||
)
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_perform_ota.assert_called_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
def test_run_ota_impl_no_addresses(
|
||||
firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock
|
||||
) -> None:
|
||||
"""Test run_ota_impl_ fails cleanly when resolution yields no addresses."""
|
||||
mock_resolve_ip.return_value = []
|
||||
|
||||
result_code, result_host = espota2.run_ota_impl_(
|
||||
"test.local", 3232, "password", str(firmware_file)
|
||||
)
|
||||
|
||||
assert result_code == 1
|
||||
assert result_host is None
|
||||
mock_sleep.assert_not_called()
|
||||
mock_socket.close.assert_called_once()
|
||||
|
||||
|
||||
def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None:
|
||||
|
||||
Reference in New Issue
Block a user