Files
esphome/tests/unit_tests/test_espota2.py
T

1503 lines
55 KiB
Python

"""Unit tests for esphome.espota2 module."""
from __future__ import annotations
from collections.abc import Generator
import gzip
import hashlib
import io
import itertools
import logging
from pathlib import Path
import socket
import struct
from unittest.mock import Mock, call, patch
import pytest
from pytest import CaptureFixture
from esphome import espota2
from esphome.core import EsphomeError
# Test constants
MOCK_MD5_CNONCE = "a" * 32 # Mock 32-char hex string from secrets.token_hex(16)
MOCK_SHA256_CNONCE = "b" * 64 # Mock 64-char hex string from secrets.token_hex(32)
MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5
MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256
@pytest.fixture
def mock_socket() -> Mock:
"""Create a mock socket for testing."""
socket_mock = Mock()
socket_mock.close = Mock()
socket_mock.recv = Mock()
socket_mock.sendall = Mock()
socket_mock.settimeout = Mock()
socket_mock.connect = Mock()
socket_mock.setsockopt = Mock()
return socket_mock
@pytest.fixture
def mock_file() -> io.BytesIO:
"""Create a mock firmware file for testing."""
return io.BytesIO(b"firmware content here")
@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]:
"""Mock time-related functions for consistent testing."""
# Monotonically increasing, never exhausted regardless of how many timing
# windows perform_ota measures or how many times a test calls it
with patch("time.perf_counter", side_effect=itertools.count()):
yield
@pytest.fixture
def mock_token_hex() -> Generator[Mock]:
"""Mock secrets.token_hex for predictable test values."""
def _token_hex(nbytes: int) -> str:
if nbytes == 16:
return MOCK_MD5_CNONCE
if nbytes == 32:
return MOCK_SHA256_CNONCE
raise ValueError(f"Unexpected nbytes for token_hex mock: {nbytes}")
with patch("esphome.espota2.secrets.token_hex", side_effect=_token_hex) as mock:
yield mock
@pytest.fixture
def mock_resolve_ip() -> Generator[Mock]:
"""Mock resolve_ip_address for testing."""
with patch("esphome.espota2.resolve_ip_address") as mock:
mock.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232))
]
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."""
with patch("esphome.espota2.perform_ota") as mock:
yield mock
@pytest.fixture
def mock_run_ota_impl() -> Generator[Mock]:
"""Mock run_ota_impl_ function for testing."""
with patch("esphome.espota2.run_ota_impl_") as mock:
mock.return_value = (0, "192.168.1.100")
yield mock
@pytest.fixture
def mock_socket_constructor(mock_socket: Mock) -> Generator[Mock]:
"""Mock socket.socket constructor to return our mock socket."""
with patch("socket.socket", return_value=mock_socket) as mock_constructor:
yield mock_constructor
def test_recv_decode_with_decode(mock_socket: Mock) -> None:
"""Test recv_decode with decode=True returns list."""
mock_socket.recv.return_value = b"\x01\x02\x03"
result = espota2.recv_decode(mock_socket, 3, decode=True)
assert result == [1, 2, 3]
mock_socket.recv.assert_called_once_with(3)
def test_recv_decode_without_decode(mock_socket: Mock) -> None:
"""Test recv_decode with decode=False returns bytes."""
mock_socket.recv.return_value = b"\x01\x02\x03"
result = espota2.recv_decode(mock_socket, 3, decode=False)
assert result == b"\x01\x02\x03"
mock_socket.recv.assert_called_once_with(3)
def test_receive_exactly_success(mock_socket: Mock) -> None:
"""Test receive_exactly successfully receives expected data."""
mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"]
result = espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK)
assert result == [0, 1, 2]
assert mock_socket.recv.call_count == 2
def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
"""Test receive_exactly raises OTAError on error response."""
mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID])
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()
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"):
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"),
[
(espota2.RESPONSE_ERROR_MAGIC, "Invalid magic byte"),
(espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Couldn't prepare flash memory"),
(espota2.RESPONSE_ERROR_AUTH_INVALID, "Authentication invalid"),
(
espota2.RESPONSE_ERROR_WRITING_FLASH,
"Writing OTA data to flash memory failed",
),
(espota2.RESPONSE_ERROR_UPDATE_END, "Finishing update failed"),
(
espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING,
"Please press the reset button",
),
(
espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG,
"ESP has been flashed with wrong flash size",
),
(
espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG,
"ESP does not have the requested flash size",
),
(
espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE,
"ESP does not have enough space",
),
(
espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE,
"The OTA partition on the ESP is too small",
),
(
espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION,
"The OTA partition on the ESP couldn't be found",
),
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Application MD5 code mismatch"),
(
espota2.RESPONSE_ERROR_SIGNATURE_INVALID,
"Firmware signature verification failed",
),
(
espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE,
"The requested OTA type is not supported by the device",
),
(
espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY,
"The partition table update could not be verified",
),
(
espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE,
"An error occurred while updating the partition table",
),
(
espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY,
"The bootloader update could not be verified",
),
(
espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE,
"An error occurred while updating the bootloader",
),
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
],
)
def test_check_error_with_various_errors(error_code: int, expected_msg: str) -> None:
"""Test check_error raises appropriate errors for different error codes."""
with pytest.raises(espota2.OTAError, match=expected_msg):
espota2.check_error([error_code], [espota2.RESPONSE_OK])
def test_check_error_unexpected_response() -> None:
"""Test check_error raises error for unexpected response."""
with pytest.raises(espota2.OTAError, match="Unexpected response from ESP: 0x7F"):
espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK])
def test_check_error_empty_data() -> None:
"""Test check_error raises the retryable OTANetworkError when the device closes the connection."""
with pytest.raises(
espota2.OTANetworkError, 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.check_error(b"", [espota2.RESPONSE_OK])
def test_send_check_with_various_data_types(mock_socket: Mock) -> None:
"""Test send_check handles different data types."""
# Test with list/tuple
espota2.send_check(mock_socket, [0x01, 0x02], "list")
mock_socket.sendall.assert_called_with(b"\x01\x02")
# Test with int
espota2.send_check(mock_socket, 0x42, "int")
mock_socket.sendall.assert_called_with(b"\x42")
# Test with string
espota2.send_check(mock_socket, "hello", "string")
mock_socket.sendall.assert_called_with(b"hello")
# Test with bytes (should pass through)
espota2.send_check(mock_socket, b"\xaa\xbb", "bytes")
mock_socket.sendall.assert_called_with(b"\xaa\xbb")
def test_send_check_socket_error(mock_socket: Mock) -> None:
"""Test send_check handles socket errors."""
mock_socket.sendall.side_effect = OSError("Broken pipe")
with pytest.raises(espota2.OTAError, match="sending test"):
espota2.send_check(mock_socket, b"data", "test")
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_md5_auth(
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test successful OTA with MD5 authentication."""
# Setup socket responses for recv calls
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request
MOCK_MD5_NONCE, # 32 char hex nonce
bytes([espota2.RESPONSE_AUTH_OK]), # Auth result
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
# Run OTA
espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin")
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify token_hex was called with MD5 digest size
mock_token_hex.assert_called_once_with(16)
# Verify cnonce was sent
cnonce = MOCK_MD5_CNONCE
assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly
expected_hash = hashlib.md5()
expected_hash.update(b"testpass")
expected_hash.update(MOCK_MD5_NONCE)
expected_hash.update(cnonce.encode())
expected_result = expected_hash.hexdigest()
assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode())
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_no_auth(
mock_socket: Mock, mock_file: io.BytesIO, caplog: pytest.LogCaptureFixture
) -> None:
"""Test OTA without authentication."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_1_0]), # 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
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
# Distinct window lengths pin each duration to its label; exactly the 6
# expected perf_counter calls, so an unaccounted timing window raises
timings = [0.0, 2.0, 10.0, 15.0, 20.0, 27.0]
with (
patch("time.perf_counter", side_effect=timings),
caplog.at_level(logging.INFO),
):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# Should not send any auth-related data
auth_calls = [
call
for call in mock_socket.sendall.call_args_list
if "cnonce" in str(call) or "result" in str(call)
]
assert len(auth_calls) == 0
# The timing summary is the observable output of the upload; exact strings
# pin each duration to its label
assert "Preparing for upload took 2.00 seconds" in caplog.text
assert (
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
in caplog.text
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_with_compression(mock_socket: Mock) -> None:
"""Test OTA with compression support."""
original_content = b"firmware" * 100 # Repeating content for compression
mock_file = io.BytesIO(original_content)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_SUPPORTS_COMPRESSION]), # Device supports compression
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
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# Verify compressed content was sent
# Get the binary size that was sent (4 bytes after features)
size_bytes = mock_socket.sendall.call_args_list[2][0][0]
sent_size = struct.unpack(">I", size_bytes)[0]
# Size should be less than original due to compression
assert sent_size < len(original_content)
# Verify the content sent was gzipped
compressed = gzip.compress(original_content, compresslevel=9)
assert sent_size == len(compressed)
def test_perform_ota_auth_without_password(mock_socket: Mock) -> None:
"""Test OTA fails when auth is required but no password provided."""
mock_file = io.BytesIO(b"firmware")
responses = [
bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_HEADER_OK]),
bytes([espota2.RESPONSE_REQUEST_AUTH]),
]
mock_socket.recv.side_effect = responses
with pytest.raises(
espota2.OTAError, match="ESP requests password, but no password given"
):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_md5_auth_wrong_password(
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test OTA fails when MD5 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request
MOCK_MD5_NONCE, # 32 char hex nonce
bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected!
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="receiving auth.*Authentication invalid"
):
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
# Verify the socket was closed after auth failure
mock_socket.close.assert_called()
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_auth_wrong_password(
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test OTA fails when SHA256 authentication is rejected due to wrong password."""
# Setup socket responses for recv calls
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request
MOCK_SHA256_NONCE, # 64 char hex nonce
bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected!
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="receiving auth.*Authentication invalid"
):
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
# Verify the socket was closed after auth failure
mock_socket.close.assert_called()
def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None:
"""Test OTA fails when SHA256 auth is required but no password provided."""
mock_file = io.BytesIO(b"firmware")
responses = [
bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_HEADER_OK]),
bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]),
]
mock_socket.recv.side_effect = responses
with pytest.raises(
espota2.OTAError, match="ESP requests password, but no password given"
):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None:
"""Test OTA fails when device sends an unexpected auth response."""
mock_file = io.BytesIO(b"firmware")
# Use 0x03 which is not in the expected auth responses
# This will be caught by check_error and raise "Unexpected response from ESP"
UNKNOWN_AUTH_METHOD = 0x03
responses = [
bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_HEADER_OK]),
bytes([UNKNOWN_AUTH_METHOD]), # Unknown auth method
]
mock_socket.recv.side_effect = responses
# This will actually raise "Unexpected response from ESP" from check_error
with pytest.raises(
espota2.OTAError, match=r"receiving auth: Unexpected response from ESP: 0x03"
):
espota2.perform_ota(mock_socket, "password", mock_file, "test.bin")
def test_perform_ota_unsupported_version(mock_socket: Mock) -> None:
"""Test OTA fails with unsupported version."""
mock_file = io.BytesIO(b"firmware")
responses = [
bytes([espota2.RESPONSE_OK, 99]), # Unsupported version
]
mock_socket.recv.side_effect = responses
with pytest.raises(espota2.OTAError, match="Device uses unsupported OTA version"):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> None:
"""Test OTA handles upload errors."""
# Setup responses - provide enough for the recv calls
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # 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
]
# Add OSError to recv to simulate connection loss during chunk read
recv_responses.append(OSError("Connection lost"))
mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="receiving chunk result response"):
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
) -> None:
"""Test run_ota_impl_ with successful upload."""
# Create a real firmware file
firmware_file = tmp_path / "firmware.bin"
firmware_file.write_bytes(b"firmware content")
# Run OTA with real file path
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
# Verify success
assert result_code == 0
assert result_host == "192.168.1.100"
# Verify socket was configured correctly
mock_socket.settimeout.assert_called_with(20.0)
mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232))
mock_socket.close.assert_called_once()
# Verify perform_ota was called with real file
mock_perform_ota.assert_called_once()
call_args = mock_perform_ota.call_args[0]
assert call_args[0] == mock_socket
assert call_args[1] == "password"
# Verify the file object is a proper file handle
assert isinstance(call_args[2], io.IOBase)
assert call_args[3] == str(firmware_file)
@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."""
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")
result_code, result_host = espota2.run_ota_impl_(
"test.local", 3232, "password", str(firmware_file)
)
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()
def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None:
"""Test run_ota_impl_ when DNS resolution fails."""
# Create a real firmware file
firmware_file = tmp_path / "firmware.bin"
firmware_file.write_bytes(b"firmware content")
mock_resolve_ip.side_effect = EsphomeError("DNS resolution failed")
with pytest.raises(espota2.OTAError, match="DNS resolution failed"):
result_code, result_host = espota2.run_ota_impl_(
"unknown.host", 3232, "password", str(firmware_file)
)
def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None:
"""Test run_ota wrapper function."""
# Test successful case
mock_run_ota_impl.return_value = (0, "192.168.1.100")
result = espota2.run_ota("test.local", 3232, "pass", "fw.bin")
assert result == (0, "192.168.1.100")
# Test error case
mock_run_ota_impl.side_effect = espota2.OTAError("Test error")
result = espota2.run_ota("test.local", 3232, "pass", "fw.bin")
assert result == (1, None)
def test_progress_bar(capsys: CaptureFixture[str]) -> None:
"""Test ProgressBar functionality."""
progress = espota2.ProgressBar("Uploading")
progress.enabled = True # Fake TTY
# Test initial update
progress.update(0.0)
captured = capsys.readouterr()
assert "0%" in captured.err
assert "[" in captured.err
# Test progress update
progress.update(0.5)
captured = capsys.readouterr()
assert "50%" in captured.err
# Test completion
progress.update(1.0)
captured = capsys.readouterr()
assert "100%" in captured.err
assert "Done" in captured.err
# Test done method
progress.done()
captured = capsys.readouterr()
assert captured.err == "\n"
# Test same progress doesn't update
progress.update(0.5)
progress.update(0.5)
captured = capsys.readouterr()
# Should only see one update (second call shouldn't write)
assert captured.err.count("50%") == 1
# Tests for SHA256 authentication
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_sha256_auth(
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test successful OTA with SHA256 authentication."""
# Setup socket responses for recv calls
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request
MOCK_SHA256_NONCE, # 64 char hex nonce
bytes([espota2.RESPONSE_AUTH_OK]), # Auth result
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
# Run OTA
espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin")
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify token_hex was called with SHA256 digest size
mock_token_hex.assert_called_once_with(32)
# Verify cnonce was sent
cnonce = MOCK_SHA256_CNONCE
assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode())
# Verify auth result was computed correctly with SHA256
expected_hash = hashlib.sha256()
expected_hash.update(b"testpass")
expected_hash.update(MOCK_SHA256_NONCE)
expected_hash.update(cnonce.encode())
expected_result = expected_hash.hexdigest()
assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode())
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_sha256_fallback_to_md5(
mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock
) -> None:
"""Test SHA256-capable client falls back to MD5 for compatibility."""
# This test verifies the temporary backward compatibility
# where a SHA256-capable client can still authenticate with MD5
# This compatibility will be removed in 2026.1.0
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
bytes(
[espota2.RESPONSE_REQUEST_AUTH]
), # MD5 Auth request (device doesn't support SHA256)
MOCK_MD5_NONCE, # 32 char hex nonce for MD5
bytes([espota2.RESPONSE_AUTH_OK]), # Auth result
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
# Run OTA - should work even though device requested MD5
espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin")
# Verify client still advertised SHA256 support
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# But authentication was done with MD5
mock_token_hex.assert_called_once_with(16)
cnonce = MOCK_MD5_CNONCE
expected_hash = hashlib.md5()
expected_hash.update(b"testpass")
expected_hash.update(MOCK_MD5_NONCE)
expected_hash.update(cnonce.encode())
expected_result = expected_hash.hexdigest()
assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode())
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_version_differences(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test OTA behavior differences between version 1.0 and 2.0."""
# Test version 1.0 - no chunk acknowledgments
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_1_0]), # 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
# No RESPONSE_CHUNK_OK for v1
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# For v1.0, verify that we only get the expected number of recv calls
# v1.0 doesn't have chunk acknowledgments, so fewer recv calls
assert mock_socket.recv.call_count == 8 # v1.0 has 8 recv calls
# Reset mock for v2.0 test
mock_socket.reset_mock()
# Reset file position for second test
mock_file.seek(0)
# Test version 2.0 - with chunk acknowledgments
recv_responses_v2 = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # 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
bytes([espota2.RESPONSE_CHUNK_OK]), # v2.0 has chunk acknowledgment
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses_v2
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# For v2.0, verify more recv calls due to chunk acknowledgments
assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_extended_protocol_app(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test OTA extended protocol app update."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Device feature flags
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
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_APP,
)
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify ota type was sent
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_APP])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_successful_partition_table(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test OTA partition table update.
The mocked server advertises both COMPRESSION and PARTITION_ACCESS to exercise
the full extended-protocol negotiation path. Real IDFOTABackend devices return
``supports_compression() == false`` and never set the COMPRESSION flag for a
partition-table OTA; the flag here is intentional protocol-coverage, not a
description of on-device behaviour.
"""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Device feature flags (compression flag is unrealistic; see docstring)
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
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"partitions.bin",
espota2.OTA_TYPE_UPDATE_PARTITION_TABLE,
)
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify ota type was sent
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_PARTITION_TABLE])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_device_rejects_with_unsupported_ota_type(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""End-to-end: device returns 0x8E after the size byte; perform_ota must
surface the human-readable 'unsupported OTA type' error from the lookup
table in check_error()."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Feature flags
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE]), # Reject at size step
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match="The requested OTA type is not supported by the device",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_APP,
)
# Verify the client did send the OTA type byte before the size step
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_APP])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_unsupported_type_rejected_early(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""ota_type values not in _SUPPORTED_OTA_TYPES are rejected before any I/O."""
with pytest.raises(espota2.OTAError, match="Unsupported OTA type 0xFF"):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
# No bytes should have been transmitted to the device.
mock_socket.sendall.assert_not_called()
@pytest.mark.parametrize("bad_type", [-1, 256, 0x10000, "app", None, 1.5])
def test_perform_ota_rejects_out_of_range_type(
mock_socket: Mock, mock_file: io.BytesIO, bad_type: object
) -> None:
"""Out-of-range or non-int ota_type must raise OTAError, not ValueError."""
with pytest.raises(espota2.OTAError, match="Invalid ota_type"):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
bad_type, # type: ignore[arg-type]
)
mock_socket.sendall.assert_not_called()
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_non_app_type_requires_extended_protocol(
mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-app OTA type must fail when device only supports the legacy protocol."""
monkeypatch.setattr(
espota2,
"_SUPPORTED_OTA_TYPES",
frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}),
)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Legacy single-byte feature ack
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match="Device does not support the extended OTA protocol",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_non_app_type_requires_partition_access(
mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-app OTA type must fail when device advertises extended protocol but
not the partition-access feature."""
monkeypatch.setattr(
espota2,
"_SUPPORTED_OTA_TYPES",
frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}),
)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker
bytes(
[espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION]
), # Compression only, no partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match=(r"running firmware was built without 'allow_partition_access: true'"),
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_partition_access_error_names_bootloader_flag(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Bootloader OTA against a stale device must point at the --bootloader flag."""
recv_responses = [
bytes([espota2.RESPONSE_OK]),
bytes([espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
bytes([0]), # No partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match=r"--bootloader.*recompile and upload.*--bootloader.*retry --bootloader",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_BOOTLOADER,
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_partition_access_error_names_partition_table_flag(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Partition-table OTA against a stale device must point at the --partition-table flag."""
recv_responses = [
bytes([espota2.RESPONSE_OK]),
bytes([espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
bytes([0]), # No partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match=r"--partition-table.*retry --partition-table",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_PARTITION_TABLE,
)
def test_check_error_detects_errors_when_expect_is_none() -> None:
"""check_error must surface device error bytes even when expect is None.
Regression test: previously, receive_exactly(..., expect=None) calls (used
during feature negotiation and nonce reads) silently passed error bytes
through, turning clean device errors into confusing later failures.
"""
with pytest.raises(espota2.OTAError, match="Authentication invalid"):
espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None)
def test_check_error_detects_empty_when_expect_is_none() -> None:
"""Empty data with expect=None must still raise (connection closed)."""
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
):
espota2.check_error([], None)
def test_check_error_passes_non_error_when_expect_is_none() -> None:
"""Non-error bytes with expect=None must pass through silently."""
espota2.check_error([espota2.RESPONSE_OK], None)
espota2.check_error([espota2.RESPONSE_HEADER_OK], None)
espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None)