[ota] Improve OTA error messages (#16327)
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Check import esphome.__main__ time (push) Has been cancelled
CI / Test downstream esphome/device-builder (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.11) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.11) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.11) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (${{ matrix.bucket.name }}) (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run CodSpeed benchmarks (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 1/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 2/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 3/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 4/4 (push) Has been cancelled
CI / Test components batch (${{ matrix.components }}) (push) Has been cancelled
CI / Test components with native ESP-IDF (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
CI for docker images / Build docker containers (docker, ubuntu-24.04) (push) Has been cancelled
CI for docker images / Build docker containers (docker, ubuntu-24.04-arm) (push) Has been cancelled
CI for docker images / Build docker containers (ha-addon, ubuntu-24.04) (push) Has been cancelled
CI for docker images / Build docker containers (ha-addon, ubuntu-24.04-arm) (push) Has been cancelled

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
This commit is contained in:
Mat931
2026-05-12 02:32:58 +00:00
committed by GitHub
parent 49df1bd30e
commit b5e50144e3
3 changed files with 50 additions and 40 deletions
@@ -68,6 +68,9 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
return OTA_RESPONSE_ERROR_WRITING_FLASH; return OTA_RESPONSE_ERROR_WRITING_FLASH;
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
// This error appears with 1 factory and 1 ota partition
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
} }
return OTA_RESPONSE_ERROR_UNKNOWN; return OTA_RESPONSE_ERROR_UNKNOWN;
} }
+15 -14
View File
@@ -118,7 +118,8 @@ _ERROR_MESSAGES: dict[int, str] = {
), ),
RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: ( RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: (
"The OTA partition on the ESP is too small. ESPHome needs to resize " "The OTA partition on the ESP is too small. ESPHome needs to resize "
"this partition, please flash over USB." "this partition. Please flash over USB or update the partition table "
"over the air."
), ),
RESPONSE_ERROR_NO_UPDATE_PARTITION: ( RESPONSE_ERROR_NO_UPDATE_PARTITION: (
"The OTA partition on the ESP couldn't be found. ESPHome needs to " "The OTA partition on the ESP couldn't be found. ESPHome needs to "
@@ -202,19 +203,19 @@ def receive_exactly(
try: try:
data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] data += recv_decode(sock, 1, decode=decode) # type: ignore[operator]
except OSError as err: except OSError as err:
raise OTAError(f"Error receiving acknowledge {msg}: {err}") from err raise OTAError(f"receiving {msg} response: {err}") from err
try: try:
check_error(data, expect) check_error(data, expect)
except OTAError as err: except OTAError as err:
sock.close() sock.close()
raise OTAError(f"Error {msg}: {err}") from err raise OTAError(f"receiving {msg}: {err}") from err
while len(data) < amount: while len(data) < amount:
try: try:
data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
except OSError as err: except OSError as err:
raise OTAError(f"Error receiving {msg}: {err}") from err raise OTAError(f"receiving {msg}: {err}") from err
return data return data
@@ -231,14 +232,14 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None
# silently passed through and surface later as cryptic decode/timeout failures. # silently passed through and surface later as cryptic decode/timeout failures.
if not data: if not data:
raise OTAError( raise OTAError(
"Error: Device closed connection without responding. " "Device closed connection without responding. "
"This may indicate the device ran out of memory, " "This may indicate the device ran out of memory, "
"a network issue, or the connection was interrupted." "a network issue, or the connection was interrupted."
) )
dat = data[0] dat = data[0]
error_msg = _ERROR_MESSAGES.get(dat) error_msg = _ERROR_MESSAGES.get(dat)
if error_msg is not None: if error_msg is not None:
raise OTAError(f"Error: {error_msg}") raise OTAError(error_msg)
if expect is None: if expect is None:
return return
if not isinstance(expect, (list, tuple)): if not isinstance(expect, (list, tuple)):
@@ -267,7 +268,7 @@ def send_check(
sock.sendall(data) sock.sendall(data)
except OSError as err: except OSError as err:
raise OTAError(f"Error sending {msg}: {err}") from err raise OTAError(f"sending {msg}: {err}") from err
def perform_ota( def perform_ota(
@@ -376,7 +377,7 @@ def perform_ota(
raise OTAError("ESP requests password, but no password given!") raise OTAError("ESP requests password, but no password given!")
nonce_bytes = receive_exactly( nonce_bytes = receive_exactly(
sock, nonce_size, f"{hash_name} authentication nonce", None, decode=False sock, nonce_size, f"{hash_name} auth nonce", None, decode=False
) )
assert isinstance(nonce_bytes, bytes) assert isinstance(nonce_bytes, bytes)
nonce = nonce_bytes.decode() nonce = nonce_bytes.decode()
@@ -424,13 +425,13 @@ def perform_ota(
(upload_size >> 0) & 0xFF, (upload_size >> 0) & 0xFF,
] ]
send_check(sock, upload_size_encoded, "binary size") send_check(sock, upload_size_encoded, "binary size")
receive_exactly(sock, 1, "binary size", RESPONSE_UPDATE_PREPARE_OK) receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK)
upload_md5 = hashlib.md5(upload_contents).hexdigest() upload_md5 = hashlib.md5(upload_contents).hexdigest()
_LOGGER.debug("MD5 of upload is %s", upload_md5) _LOGGER.debug("MD5 of upload is %s", upload_md5)
send_check(sock, upload_md5, "file checksum") send_check(sock, upload_md5, "file checksum")
receive_exactly(sock, 1, "file checksum", RESPONSE_BIN_MD5_OK) receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK)
# Disable nodelay for transfer # Disable nodelay for transfer
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
@@ -451,10 +452,10 @@ def perform_ota(
try: try:
sock.sendall(chunk) sock.sendall(chunk)
if version >= OTA_VERSION_2_0: if version >= OTA_VERSION_2_0:
receive_exactly(sock, 1, "chunk OK", RESPONSE_CHUNK_OK) receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
except OSError as err: except OSError as err:
sys.stderr.write("\n") sys.stderr.write("\n")
raise OTAError(f"Error sending data: {err}") from err raise OTAError(f"sending data: {err}") from err
progress.update(offset / upload_size) progress.update(offset / upload_size)
progress.done() progress.done()
@@ -465,8 +466,8 @@ def perform_ota(
_LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration)
receive_exactly(sock, 1, "receive OK", RESPONSE_RECEIVE_OK) receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
receive_exactly(sock, 1, "Update end", RESPONSE_UPDATE_END_OK) receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
send_check(sock, RESPONSE_OK, "end acknowledgement") send_check(sock, RESPONSE_OK, "end acknowledgement")
_LOGGER.info("OTA successful") _LOGGER.info("OTA successful")
+32 -26
View File
@@ -135,7 +135,9 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
"""Test receive_exactly raises OTAError on error response.""" """Test receive_exactly raises OTAError on error response."""
mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID])
with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): with pytest.raises(
espota2.OTAError, match="receiving auth:.*Authentication invalid"
):
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
mock_socket.close.assert_called_once() mock_socket.close.assert_called_once()
@@ -145,69 +147,69 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
"""Test receive_exactly handles socket errors.""" """Test receive_exactly handles socket errors."""
mock_socket.recv.side_effect = OSError("Connection reset") mock_socket.recv.side_effect = OSError("Connection reset")
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"): with pytest.raises(espota2.OTAError, match="receiving test response"):
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("error_code", "expected_msg"), ("error_code", "expected_msg"),
[ [
(espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"), (espota2.RESPONSE_ERROR_MAGIC, "Invalid magic byte"),
(espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"), (espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Couldn't prepare flash memory"),
(espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"), (espota2.RESPONSE_ERROR_AUTH_INVALID, "Authentication invalid"),
( (
espota2.RESPONSE_ERROR_WRITING_FLASH, espota2.RESPONSE_ERROR_WRITING_FLASH,
"Error: Writing OTA data to flash memory failed", "Writing OTA data to flash memory failed",
), ),
(espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"), (espota2.RESPONSE_ERROR_UPDATE_END, "Finishing update failed"),
( (
espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING, espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING,
"Error: Please press the reset button", "Please press the reset button",
), ),
( (
espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG, espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG,
"Error: ESP has been flashed with wrong flash size", "ESP has been flashed with wrong flash size",
), ),
( (
espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG, espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG,
"Error: ESP does not have the requested flash size", "ESP does not have the requested flash size",
), ),
( (
espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE, espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE,
"Error: ESP does not have enough space", "ESP does not have enough space",
), ),
( (
espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE, espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE,
"Error: The OTA partition on the ESP is too small", "The OTA partition on the ESP is too small",
), ),
( (
espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION, espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION,
"Error: The OTA partition on the ESP couldn't be found", "The OTA partition on the ESP couldn't be found",
), ),
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Application MD5 code mismatch"),
( (
espota2.RESPONSE_ERROR_SIGNATURE_INVALID, espota2.RESPONSE_ERROR_SIGNATURE_INVALID,
"Error: Firmware signature verification failed", "Firmware signature verification failed",
), ),
( (
espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE, espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE,
"Error: The requested OTA type is not supported by the device", "The requested OTA type is not supported by the device",
), ),
( (
espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY, espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY,
"Error: The partition table update could not be verified", "The partition table update could not be verified",
), ),
( (
espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE, espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE,
"Error: An error occurred while updating the partition table", "An error occurred while updating the partition table",
), ),
( (
espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY, espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY,
"Error: The bootloader update could not be verified", "The bootloader update could not be verified",
), ),
( (
espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE,
"Error: An error occurred while updating the bootloader", "An error occurred while updating the bootloader",
), ),
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
], ],
@@ -262,7 +264,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None:
"""Test send_check handles socket errors.""" """Test send_check handles socket errors."""
mock_socket.sendall.side_effect = OSError("Broken pipe") mock_socket.sendall.side_effect = OSError("Broken pipe")
with pytest.raises(espota2.OTAError, match="Error sending test"): with pytest.raises(espota2.OTAError, match="sending test"):
espota2.send_check(mock_socket, b"data", "test") espota2.send_check(mock_socket, b"data", "test")
@@ -417,7 +419,9 @@ def test_perform_ota_md5_auth_wrong_password(
mock_socket.recv.side_effect = recv_responses mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): with pytest.raises(
espota2.OTAError, match="receiving auth.*Authentication invalid"
):
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
# Verify the socket was closed after auth failure # Verify the socket was closed after auth failure
@@ -441,7 +445,9 @@ def test_perform_ota_sha256_auth_wrong_password(
mock_socket.recv.side_effect = recv_responses mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): with pytest.raises(
espota2.OTAError, match="receiving auth.*Authentication invalid"
):
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
# Verify the socket was closed after auth failure # Verify the socket was closed after auth failure
@@ -484,7 +490,7 @@ def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None:
# This will actually raise "Unexpected response from ESP" from check_error # This will actually raise "Unexpected response from ESP" from check_error
with pytest.raises( with pytest.raises(
espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03" espota2.OTAError, match=r"receiving auth: Unexpected response from ESP: 0x03"
): ):
espota2.perform_ota(mock_socket, "password", mock_file, "test.bin") espota2.perform_ota(mock_socket, "password", mock_file, "test.bin")
@@ -520,7 +526,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
mock_socket.recv.side_effect = recv_responses mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"): with pytest.raises(espota2.OTAError, match="receiving chunk result response"):
espota2.perform_ota(mock_socket, None, mock_file, "test.bin") espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@@ -1109,7 +1115,7 @@ def test_check_error_detects_errors_when_expect_is_none() -> None:
during feature negotiation and nonce reads) silently passed error bytes during feature negotiation and nonce reads) silently passed error bytes
through, turning clean device errors into confusing later failures. through, turning clean device errors into confusing later failures.
""" """
with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"): with pytest.raises(espota2.OTAError, match="Authentication invalid"):
espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None) espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None)