diff --git a/esphome/espota2.py b/esphome/espota2.py index 979e97947a..2dd5409452 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -174,13 +174,18 @@ _ERROR_MESSAGES: dict[int, str] = { RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } +# Device-reported errors that do not persist across attempts: an MD5 mismatch +# means the transfer arrived corrupted and the device aborted without +# committing, so a fresh upload may succeed. +_RETRYABLE_ERROR_CODES: frozenset[int] = frozenset({RESPONSE_ERROR_MD5_MISMATCH}) + class OTAError(EsphomeError): pass class OTANetworkError(OTAError): - """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + """Transient OTA failure (timeout, reset, closed connection, corrupted transfer); retrying may succeed.""" def _committed_error(err: OTANetworkError) -> OTAError: @@ -273,6 +278,8 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None dat = data[0] error_msg = _ERROR_MESSAGES.get(dat) if error_msg is not None: + if dat in _RETRYABLE_ERROR_CODES: + raise OTANetworkError(error_msg) raise OTAError(error_msg) if expect is None: return @@ -492,9 +499,13 @@ def perform_ota( # 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 - with contextlib.suppress(OSError, OTANetworkError): + 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 if version >= OTA_VERSION_2_0: @@ -581,7 +592,10 @@ def run_ota_impl_( # 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. + # 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 diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 7819c48244..dc9c586788 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -274,6 +274,12 @@ def test_check_error_unexpected_response() -> None: espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) +def test_check_error_md5_mismatch_is_retryable() -> None: + """Test check_error raises the retryable OTANetworkError for an MD5 mismatch.""" + with pytest.raises(espota2.OTANetworkError, match="MD5 code mismatch"): + espota2.check_error([espota2.RESPONSE_ERROR_MD5_MISMATCH], None) + + def test_check_error_empty_data() -> None: """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises(