[ota] Add bootloader update functionality to ota component (#16238)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Mat931
2026-05-08 21:36:06 +00:00
committed by GitHub
parent 3abf2c99a2
commit 1365251365
12 changed files with 570 additions and 51 deletions

View File

@@ -853,6 +853,23 @@ class TestEsphomeCore:
target.testing_ensure_platform_registered("sensor")
assert target.platform_counts["sensor"] == 3
def test_bootloader_bin__native_idf(self, target):
"""Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin."""
target.data[const.KEY_NATIVE_IDF] = True
assert target.bootloader_bin == Path(
"foo/build/build/bootloader/bootloader.bin"
)
def test_bootloader_bin__platformio(self, target):
"""For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory."""
target.name = "test-device"
target.data[const.KEY_NATIVE_IDF] = False
assert target.bootloader_bin == Path(
"foo/build/.pioenvs/test-device/bootloader.bin"
)
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {

View File

@@ -201,6 +201,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE,
"Error: An error occurred while updating the partition table",
),
(
espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY,
"Error: The bootloader update could not be verified",
),
(
espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE,
"Error: An error occurred while updating the bootloader",
),
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
],
)
@@ -992,7 +1000,8 @@ def test_perform_ota_non_app_type_requires_extended_protocol(
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support extended OTA protocol"
espota2.OTAError,
match="Device does not support the extended OTA protocol",
):
espota2.perform_ota(
mock_socket,
@@ -1026,7 +1035,8 @@ def test_perform_ota_non_app_type_requires_partition_access(
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support partition access"
espota2.OTAError,
match=(r"running firmware was built without 'allow_partition_access: true'"),
):
espota2.perform_ota(
mock_socket,
@@ -1037,6 +1047,60 @@ def test_perform_ota_non_app_type_requires_partition_access(
)
@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.

View File

@@ -24,6 +24,7 @@ from esphome.__main__ import (
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_resolve_network_devices,
_validate_bootloader_binary,
_validate_partition_table_binary,
choose_upload_log_host,
command_analyze_memory,
@@ -89,7 +90,11 @@ from esphome.const import (
PLATFORM_RP2040,
)
from esphome.core import CORE, EsphomeError
from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE
from esphome.espota2 import (
OTA_TYPE_UPDATE_APP,
OTA_TYPE_UPDATE_BOOTLOADER,
OTA_TYPE_UPDATE_PARTITION_TABLE,
)
from esphome.util import BootselResult, FlashImage
from esphome.zeroconf import _await_discovery, discover_mdns_devices
@@ -1127,6 +1132,7 @@ class MockArgs:
output: str | None = None
ota_platform: str | None = None
partition_table: bool = False
bootloader: bool = False
def test_upload_program_serial_esp32(
@@ -1816,6 +1822,27 @@ def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None:
_validate_partition_table_binary(tmp_path / "does-not-exist.bin")
def test_validate_bootloader_binary_rejects_wrong_magic(tmp_path: Path) -> None:
data = bytearray(_make_bootloader_bytes())
data[0] = 0x00
f = tmp_path / "bootloader.bin"
f.write_bytes(bytes(data))
with pytest.raises(EsphomeError, match="magic"):
_validate_bootloader_binary(f)
def test_validate_bootloader_binary_missing_file(tmp_path: Path) -> None:
with pytest.raises(EsphomeError, match="Cannot read bootloader file"):
_validate_bootloader_binary(tmp_path / "does-not-exist.bin")
def test_validate_bootloader_binary_rejects_empty_file(tmp_path: Path) -> None:
f = tmp_path / "bootloader.bin"
f.write_bytes(b"")
with pytest.raises(EsphomeError, match="is empty"):
_validate_bootloader_binary(f)
def test_upload_program_ota_partition_table_invalid_file(
mock_run_ota: Mock,
mock_get_port_type: Mock,
@@ -1869,7 +1896,155 @@ def test_upload_program_ota_partition_table_without_allow_flag(
with pytest.raises(
EsphomeError,
match="requires 'allow_partition_access: true'",
match=(
r"The option --partition-table requires 'allow_partition_access: true'.*"
r"retry --partition-table"
),
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def _make_bootloader_bytes() -> bytes:
"""Build a minimal bootloader image accepted by _validate_bootloader_binary."""
table = bytearray(b"\xff")
# Starts with: ESP_IMAGE_HEADER_MAGIC (0xE9)
table[0] = 0xE9
return bytes(table)
def test_upload_program_ota_bootloader_with_file_arg(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""Test upload_program with OTA and bootloader."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
bootloader_file = tmp_path / "bootloader.bin"
bootloader_file.write_bytes(_make_bootloader_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(bootloader_file), bootloader=True)
devices = ["192.168.1.100"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"],
3232,
None,
bootloader_file,
OTA_TYPE_UPDATE_BOOTLOADER,
)
def test_upload_program_ota_partition_table_and_bootloader_options(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--partition-table and --bootloader can't be used together."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(file="partitions.bin", partition_table=True, bootloader=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match="--partition-table and --bootloader",
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def test_upload_program_ota_bootloader_without_allow_flag(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--bootloader must fail fast when allow_partition_access is not enabled in YAML."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
]
}
args = MockArgs(file="bootloader.bin", bootloader=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match=(
r"The option --bootloader requires 'allow_partition_access: true'.*"
r"retry --bootloader"
),
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()
def test_upload_program_ota_bootloader_platform_web_server(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""Test bootloader upload with web_server OTA."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
bootloader_file = tmp_path / "bootloader.bin"
bootloader_file.write_bytes(_make_bootloader_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_WEB_SERVER,
CONF_WEB_SERVER: {
CONF_PORT: 80,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
},
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(bootloader_file), bootloader=True)
devices = ["192.168.1.100"]
with pytest.raises(
EsphomeError,
match="the web_server OTA path can only update the firmware image",
):
upload_program(config, args, devices)
mock_run_ota.assert_not_called()