mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 18:11:29 +00:00
[espidf] Add --skip-bootloader for OTA only builds (#19925)
This commit is contained in:
@@ -14,6 +14,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
|
||||
|
||||
import check_idf_py_equivalence as guard # noqa: E402
|
||||
|
||||
from esphome.build_gen import espidf as build_gen # noqa: E402
|
||||
from esphome.core import CORE # noqa: E402
|
||||
from esphome.espidf import toolchain # noqa: E402
|
||||
|
||||
@@ -25,12 +26,26 @@ def _reset_core() -> Iterator[None]:
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def _make_tree(tmp_path: Path) -> Path:
|
||||
BOOTLOADER_LOG = "build/bootloader/.ninja_log"
|
||||
ALL_LOGS = (guard.TOP_NINJA_LOG, BOOTLOADER_LOG)
|
||||
|
||||
|
||||
def _make_tree(tmp_path: Path, skip_bootloader: bool = False) -> Path:
|
||||
"""A fake build tree; stock shape by default, or the skip shape
|
||||
(define set to 1, no bootloader bin, no sub-build)."""
|
||||
tree = tmp_path / "config" / ".esphome" / "build" / "dev"
|
||||
build = tree / "build"
|
||||
for name in (*guard.watched("dev"), *guard.NINJA_LOGS):
|
||||
files = [
|
||||
*guard.watched("dev", skip_bootloader),
|
||||
*guard._ninja_logs(skip_bootloader),
|
||||
]
|
||||
for name in files:
|
||||
(tree / name).parent.mkdir(parents=True, exist_ok=True)
|
||||
(tree / name).write_bytes(b"x")
|
||||
define = "1" if skip_bootloader else "0"
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED={define}\n"
|
||||
)
|
||||
(build / "project_description.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -44,9 +59,10 @@ def _make_tree(tmp_path: Path) -> Path:
|
||||
"# ninja log v7\n1\t2\t10\tesp-idf/a.obj\t0\n"
|
||||
"1\t2\t10\tbootloader/bootloader.bin\t0\n"
|
||||
)
|
||||
(build / "bootloader" / ".ninja_log").write_text(
|
||||
"# ninja log v7\n1\t2\t10\tbootloader.elf\t0\n"
|
||||
)
|
||||
if not skip_bootloader:
|
||||
(build / "bootloader" / ".ninja_log").write_text(
|
||||
"# ninja log v7\n1\t2\t10\tbootloader.elf\t0\n"
|
||||
)
|
||||
(tree / "sdkconfig.dev").write_text("")
|
||||
return tree
|
||||
|
||||
@@ -56,6 +72,7 @@ def _run_check(
|
||||
side_effect: Callable[[list[str]], None] = lambda cmd: None,
|
||||
rc: int = 0,
|
||||
esphome_rcs: tuple[int, int] = (0, 0),
|
||||
macro_matches: bool = True,
|
||||
envs: list[dict[str, str]] | None = None,
|
||||
) -> tuple[list[str], list[list[str]]]:
|
||||
"""Run check() with idf.py replaced by ``side_effect``; return problems, calls.
|
||||
@@ -80,6 +97,7 @@ def _run_check(
|
||||
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
|
||||
patch.object(toolchain, "run_reconfigure", return_value=esphome_rcs[0]),
|
||||
patch.object(toolchain, "_run_ninja", return_value=esphome_rcs[1]),
|
||||
patch.object(build_gen, "idf_macro_matches", return_value=macro_matches),
|
||||
patch.object(guard.subprocess, "run", side_effect=run),
|
||||
patch.dict(os.environ),
|
||||
):
|
||||
@@ -168,9 +186,7 @@ def test_check_stops_when_idf_py_fails(tmp_path: Path) -> None:
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"remove", ["build/build.ninja", "build/dev.bin", *guard.NINJA_LOGS]
|
||||
)
|
||||
@pytest.mark.parametrize("remove", ["build/build.ninja", "build/dev.bin", *ALL_LOGS])
|
||||
def test_check_fails_when_an_input_is_missing(tmp_path: Path, remove: str) -> None:
|
||||
"""A moved or renamed output must not compare as unchanged."""
|
||||
tree = _make_tree(tmp_path)
|
||||
@@ -197,7 +213,7 @@ def test_check_stops_when_the_esphome_baseline_fails(
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log", guard.NINJA_LOGS)
|
||||
@pytest.mark.parametrize("log", ALL_LOGS)
|
||||
def test_check_fails_when_a_ninja_log_has_no_entries(tmp_path: Path, log: str) -> None:
|
||||
"""A log format change must not leave the rebuild check with nothing to compare."""
|
||||
tree = _make_tree(tmp_path)
|
||||
@@ -223,6 +239,52 @@ def test_main_rejects_a_path_that_is_not_a_tree(
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
def test_check_resets_the_skip_memo_per_tree(tmp_path: Path) -> None:
|
||||
"""A second tree must not inherit the first tree's memoized mode."""
|
||||
tree = _make_tree(tmp_path)
|
||||
CORE.skip_bootloader = True
|
||||
toolchain._cache().skip_bootloader = True # leftover from a prior tree
|
||||
problems, _ = _run_check(tree)
|
||||
assert problems == []
|
||||
assert toolchain._skip_bootloader() is False
|
||||
|
||||
|
||||
def test_check_accepts_a_skip_bootloader_tree(tmp_path: Path) -> None:
|
||||
"""No bootloader bin or sub-build is the skip shape, not missing input."""
|
||||
tree = _make_tree(tmp_path, skip_bootloader=True)
|
||||
problems, calls = _run_check(tree)
|
||||
assert problems == []
|
||||
assert len(calls) == 2
|
||||
# The baseline reconfigure must not flip the tree's mode.
|
||||
assert CORE.skip_bootloader is True
|
||||
|
||||
|
||||
def test_check_flags_an_ineffective_override(tmp_path: Path) -> None:
|
||||
"""A skip-mode tree that still built a bootloader must fail CI."""
|
||||
tree = _make_tree(tmp_path, skip_bootloader=True)
|
||||
(tree / guard.BOOTLOADER_BIN).parent.mkdir(parents=True)
|
||||
(tree / guard.BOOTLOADER_BIN).write_bytes(b"x")
|
||||
problems, _ = _run_check(tree)
|
||||
assert problems == [guard.OVERRIDE_INEFFECTIVE]
|
||||
|
||||
|
||||
def test_check_requires_the_sub_log_on_a_stock_tree(tmp_path: Path) -> None:
|
||||
"""The mode comes from the define, so a vanished sub-build stays an error."""
|
||||
tree = _make_tree(tmp_path)
|
||||
(tree / BOOTLOADER_LOG).unlink()
|
||||
problems, calls = _run_check(tree)
|
||||
assert problems == [f"missing {BOOTLOADER_LOG}"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_check_fails_loudly_when_the_idf_macro_changed(tmp_path: Path) -> None:
|
||||
"""An IDF bump that rewrites the overridden macro must fail CI."""
|
||||
tree = _make_tree(tmp_path)
|
||||
problems, calls = _run_check(tree, macro_matches=False)
|
||||
assert problems == [guard.MACRO_CHANGED]
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_main_without_build_trees(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
||||
@@ -670,3 +670,75 @@ def test_pch_checksum_disabled_writes_nothing(
|
||||
_make_pch_project(tmp_path)
|
||||
write_pch_checksum()
|
||||
assert not CORE.relative_build_path(_PCH_SUM_PATH).exists()
|
||||
|
||||
|
||||
# The macro body as shipped in tools/cmake/build.cmake; byte identical in
|
||||
# IDF 5.5.5 and 6.1.0, so one fixture covers both supported versions.
|
||||
IDF_BUILD_CMAKE = """\
|
||||
some_other_cmake()
|
||||
|
||||
macro(__build_process_project_includes)
|
||||
# Include the sdkconfig cmake file, since the following operations require
|
||||
# knowledge of config values.
|
||||
idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE)
|
||||
include(${sdkconfig_cmake})
|
||||
|
||||
# Make each build property available as a read-only variable
|
||||
idf_build_get_property(build_properties __BUILD_PROPERTIES)
|
||||
foreach(build_property ${build_properties})
|
||||
idf_build_get_property(val ${build_property})
|
||||
set(${build_property} "${val}")
|
||||
endforeach()
|
||||
|
||||
idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS)
|
||||
|
||||
# Include each component's project_include.cmake
|
||||
foreach(component_target ${build_component_targets})
|
||||
__component_get_property(dir ${component_target} COMPONENT_DIR)
|
||||
__component_get_property(_name ${component_target} COMPONENT_NAME)
|
||||
set(COMPONENT_NAME ${_name})
|
||||
set(COMPONENT_DIR ${dir})
|
||||
set(COMPONENT_PATH ${dir}) # this is deprecated, users are encouraged to use COMPONENT_DIR;
|
||||
# retained for compatibility
|
||||
if(EXISTS ${COMPONENT_DIR}/project_include.cmake)
|
||||
include(${COMPONENT_DIR}/project_include.cmake)
|
||||
endif()
|
||||
endforeach()
|
||||
endmacro()
|
||||
"""
|
||||
|
||||
|
||||
def _write_idf_build_cmake(tmp_path: Path, text: str = IDF_BUILD_CMAKE) -> Path:
|
||||
idf = tmp_path / "idf"
|
||||
(idf / "tools" / "cmake").mkdir(parents=True)
|
||||
(idf / "tools" / "cmake" / "build.cmake").write_text(text)
|
||||
return idf
|
||||
|
||||
|
||||
def test_normalized_macro_strips_comments_and_whitespace() -> None:
|
||||
from esphome.build_gen.espidf import _normalized_macro
|
||||
|
||||
text = "macro(__build_process_project_includes)\n a( b ) # tail\n\n # only\n c(d)\nendmacro()"
|
||||
assert _normalized_macro(text) == ["a( b )", "c(d)"]
|
||||
|
||||
|
||||
def test_normalized_macro_none_without_macro() -> None:
|
||||
from esphome.build_gen.espidf import _normalized_macro
|
||||
|
||||
assert _normalized_macro("nothing here") is None
|
||||
|
||||
|
||||
def test_idf_macro_matches_the_shipped_body(tmp_path: Path) -> None:
|
||||
"""The override's embedded copy must equal what build.cmake ships."""
|
||||
from esphome.build_gen.espidf import idf_macro_matches
|
||||
|
||||
assert idf_macro_matches(_write_idf_build_cmake(tmp_path)) is True
|
||||
|
||||
|
||||
def test_idf_macro_mismatch_detected(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import idf_macro_matches
|
||||
|
||||
changed = IDF_BUILD_CMAKE.replace(
|
||||
"include(${sdkconfig_cmake})", "include(${sdkconfig_cmake} NEW_ARG)"
|
||||
)
|
||||
assert idf_macro_matches(_write_idf_build_cmake(tmp_path, changed)) is False
|
||||
|
||||
@@ -52,6 +52,35 @@ def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path)
|
||||
)
|
||||
|
||||
|
||||
def test_esp32_skip_bootloader_tree_offers_no_factory_entry(tmp_path: Path) -> None:
|
||||
"""A --skip-bootloader tree has no factory image; do not offer one.
|
||||
|
||||
An unreadable tree (PlatformIO, capability probes passing a bare
|
||||
path) reads as a full build and keeps the entry.
|
||||
"""
|
||||
from esphome.espidf.toolchain import SKIP_BOOTLOADER_DEFINE
|
||||
|
||||
storage = _wizard_storage()
|
||||
storage.firmware_bin_path = tmp_path / "firmware.bin"
|
||||
|
||||
files = [entry["file"] for entry in _download_types("esp32", storage)]
|
||||
assert files == ["firmware.factory.bin", "firmware.ota.bin"]
|
||||
|
||||
cache = tmp_path / "CMakeCache.txt"
|
||||
cache.write_text(f"{SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n")
|
||||
files = [entry["file"] for entry in _download_types("esp32", storage)]
|
||||
assert files == ["firmware.ota.bin"]
|
||||
|
||||
# A capability probe passes the path as a plain string.
|
||||
storage.firmware_bin_path = str(tmp_path / "firmware.bin")
|
||||
files = [entry["file"] for entry in _download_types("esp32", storage)]
|
||||
assert files == ["firmware.ota.bin"]
|
||||
|
||||
cache.write_text(f"{SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n")
|
||||
files = [entry["file"] for entry in _download_types("esp32", storage)]
|
||||
assert files == ["firmware.factory.bin", "firmware.ota.bin"]
|
||||
|
||||
|
||||
def _nrf52_files(tmp_path: Path, *built: str) -> list[str]:
|
||||
"""The files nrf52 offers for a build directory holding *built*."""
|
||||
(tmp_path / "zephyr").mkdir()
|
||||
|
||||
@@ -8,11 +8,16 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import call, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_ESP32,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
@@ -893,6 +898,7 @@ def test_run_reconfigure_cmake_argv_matches_idf_py(setup_core: Path) -> None:
|
||||
"-DESP_PLATFORM=1",
|
||||
f"-DSDKCONFIG={sdkconfig}",
|
||||
"-DCCACHE_ENABLE=0",
|
||||
"-DESPHOME_SKIP_BOOTLOADER=0",
|
||||
project,
|
||||
]
|
||||
kwargs = mock_run.call_args.kwargs
|
||||
@@ -927,6 +933,7 @@ def test_run_reconfigure_cmake_argv_matches_idf6_py(
|
||||
"-DPYTHON=/tools/python",
|
||||
"-DESP_PLATFORM=1",
|
||||
f"-DCCACHE_ENABLE={expected}",
|
||||
"-DESPHOME_SKIP_BOOTLOADER=0",
|
||||
str(build_dir.parent),
|
||||
]
|
||||
|
||||
@@ -938,7 +945,8 @@ def test_run_reconfigure_without_sdkconfig_or_filter(setup_core: Path) -> None:
|
||||
assert toolchain.run_reconfigure(verbose=True) == 0
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert not any(arg.startswith("-DSDKCONFIG=") for arg in cmd)
|
||||
assert cmd[-2] == "-DCCACHE_ENABLE=0"
|
||||
assert cmd[-3] == "-DCCACHE_ENABLE=0"
|
||||
assert cmd[-2] == "-DESPHOME_SKIP_BOOTLOADER=0"
|
||||
assert mock_run.call_args.kwargs["filter_lines"] is None
|
||||
|
||||
|
||||
@@ -1005,6 +1013,7 @@ _CONFIGURED = (
|
||||
"PYTHON_DEPS_CHECKED:UNINITIALIZED=1\n"
|
||||
"PYTHON:UNINITIALIZED=/tools/python\n"
|
||||
"ESP_PLATFORM:UNINITIALIZED=1\n"
|
||||
"ESPHOME_SKIP_BOOTLOADER:UNINITIALIZED=0\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -1256,3 +1265,206 @@ def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> N
|
||||
with patch.object(toolchain, "_get_cmake_output", return_value=output):
|
||||
assert toolchain.get_cmake_cache_value("ESPHOME_PCH") == "OFF"
|
||||
assert toolchain.get_cmake_cache_value("ESPHOME_MISSING") is None
|
||||
|
||||
|
||||
def test_skip_bootloader_requires_flag_and_matching_macro(
|
||||
setup_core: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The switch is explicit, and an IDF macro change wins over it."""
|
||||
from esphome.build_gen import espidf as build_gen
|
||||
|
||||
assert toolchain._skip_bootloader() is False
|
||||
CORE.skip_bootloader = True
|
||||
toolchain._cache().skip_bootloader = None # decision is memoized per run
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
|
||||
patch.object(build_gen, "idf_macro_matches", return_value=True),
|
||||
):
|
||||
assert toolchain._skip_bootloader() is True
|
||||
toolchain._cache().skip_bootloader = None
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
|
||||
patch.object(build_gen, "idf_macro_matches", return_value=False) as mock_match,
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
assert toolchain._skip_bootloader() is False
|
||||
assert toolchain._skip_bootloader() is False
|
||||
mock_match.assert_called_once() # the memo also dedupes the warning
|
||||
assert "--skip-bootloader ignored" in caplog.text
|
||||
|
||||
|
||||
def test_configure_defines_follow_skip_bootloader(setup_core: Path) -> None:
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_tool", return_value="/tools/python"),
|
||||
patch.object(toolchain, "_cache_entries", return_value={}),
|
||||
):
|
||||
with patch.object(toolchain, "_skip_bootloader", return_value=True):
|
||||
assert (
|
||||
toolchain._configure_defines()[toolchain.SKIP_BOOTLOADER_DEFINE] == "1"
|
||||
)
|
||||
with patch.object(toolchain, "_skip_bootloader", return_value=False):
|
||||
assert (
|
||||
toolchain._configure_defines()[toolchain.SKIP_BOOTLOADER_DEFINE] == "0"
|
||||
)
|
||||
|
||||
|
||||
def test_tree_skips_bootloader_reads_the_define(tmp_path: Path) -> None:
|
||||
build = tmp_path / "build"
|
||||
build.mkdir()
|
||||
assert toolchain.tree_skips_bootloader(build) is False # not configured
|
||||
cache = build / "CMakeCache.txt"
|
||||
cache.write_text(f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n")
|
||||
assert toolchain.tree_skips_bootloader(build) is True
|
||||
cache.write_text(f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n")
|
||||
assert toolchain.tree_skips_bootloader(build) is False
|
||||
|
||||
|
||||
def test_tree_skips_bootloader_unreadable_cache_reads_full(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Unreadable trees fall back to the safe full build, with a trace."""
|
||||
build = tmp_path / "build"
|
||||
(build / "CMakeCache.txt").mkdir(parents=True) # read raises OSError
|
||||
with caplog.at_level("DEBUG"):
|
||||
assert toolchain.tree_skips_bootloader(build) is False
|
||||
assert "assuming a full build" in caplog.text
|
||||
|
||||
|
||||
def test_create_factory_bin_merges_the_listed_flash_files(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Every listed file lands in the esptool merge argv, address sorted."""
|
||||
_setup_build(setup_core)
|
||||
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = "4MB"
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
|
||||
)
|
||||
(build / "app.bin").write_bytes(b"app")
|
||||
(build / "boot.bin").write_bytes(b"boot")
|
||||
(build / "flasher_args.json").write_text(
|
||||
'{"flash_files": {"0x10000": "app.bin", "0x0": "boot.bin"},'
|
||||
' "extra_esptool_args": {"chip": "esp32"}}'
|
||||
)
|
||||
with (
|
||||
patch.object(toolchain, "_get_idf_env", return_value={}),
|
||||
patch.object(toolchain, "_get_idf_tool", return_value="/py"),
|
||||
patch.object(
|
||||
toolchain.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert toolchain.create_factory_bin() is True
|
||||
argv = mock_run.call_args.args[0]
|
||||
boot = argv.index("0x0")
|
||||
assert argv[boot + 1].endswith("boot.bin")
|
||||
assert argv[boot + 2] == "0x10000"
|
||||
|
||||
|
||||
def test_create_factory_bin_fails_on_a_missing_listed_flash_file(
|
||||
setup_core: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A partial factory image would not boot; never write one."""
|
||||
_setup_build(setup_core)
|
||||
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = "4MB"
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
|
||||
)
|
||||
(build / "flasher_args.json").write_text('{"flash_files": {"0x0": "missing.bin"}}')
|
||||
stale = toolchain.get_factory_firmware_path()
|
||||
stale.write_bytes(b"old")
|
||||
with patch.object(toolchain.subprocess, "run") as mock_run:
|
||||
assert toolchain.create_factory_bin() is False
|
||||
mock_run.assert_not_called()
|
||||
assert "Flash file not found" in caplog.text
|
||||
# The image from an earlier build must not be served as this one.
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_create_factory_bin_skip_mode_creates_nothing(
|
||||
setup_core: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The flip into skip mode already cleaned up; the merge just no-ops."""
|
||||
_setup_build(setup_core)
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
|
||||
)
|
||||
with (
|
||||
patch.object(toolchain.subprocess, "run") as mock_run,
|
||||
caplog.at_level("INFO"),
|
||||
):
|
||||
assert toolchain.create_factory_bin() is True
|
||||
mock_run.assert_not_called()
|
||||
assert "no factory image" in caplog.text
|
||||
|
||||
|
||||
def test_run_reconfigure_flip_into_skip_mode_cleans_up(setup_core: Path) -> None:
|
||||
"""Full-mode leftovers are stale for OTA --bootloader and downloads, and
|
||||
a partial cleanup would poison the flip back to full mode."""
|
||||
_setup_build(setup_core)
|
||||
build = CORE.relative_build_path("build")
|
||||
(build / "bootloader").mkdir(parents=True)
|
||||
(build / "bootloader" / "bootloader.bin").write_bytes(b"old")
|
||||
# bootloader-prefix deliberately absent: cleanup skips what is not there.
|
||||
stale = toolchain.get_factory_firmware_path()
|
||||
stale.parent.mkdir(parents=True, exist_ok=True)
|
||||
stale.write_bytes(b"old")
|
||||
with (
|
||||
patch.object(toolchain, "_skip_bootloader", return_value=True),
|
||||
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
|
||||
patch.object(toolchain, "_tool_env", return_value={}),
|
||||
patch.object(toolchain, "run_build_tool", return_value=0),
|
||||
patch.object(toolchain, "_idf_py") as mock_idf_py,
|
||||
):
|
||||
mock_idf_py.return_value.binary_dir_arg = False
|
||||
assert toolchain.run_reconfigure() == 0
|
||||
assert not (build / "bootloader").exists()
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_run_reconfigure_skip_steady_state_cleans_nothing(setup_core: Path) -> None:
|
||||
"""Cleanup belongs to the flip; a reconfigure of a skip tree touches nothing."""
|
||||
_setup_build(setup_core)
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
|
||||
)
|
||||
marker = build / "bootloader"
|
||||
marker.mkdir()
|
||||
with (
|
||||
patch.object(toolchain, "_skip_bootloader", return_value=True),
|
||||
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
|
||||
patch.object(toolchain, "_tool_env", return_value={}),
|
||||
patch.object(toolchain, "run_build_tool", return_value=0),
|
||||
patch.object(toolchain, "_idf_py") as mock_idf_py,
|
||||
):
|
||||
mock_idf_py.return_value.binary_dir_arg = False
|
||||
assert toolchain.run_reconfigure() == 0
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
def test_create_factory_bin_full_mode_needs_flasher_args(setup_core: Path) -> None:
|
||||
"""Past the skip gate, a full tree without flasher_args fails as before."""
|
||||
_setup_build(setup_core)
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
|
||||
)
|
||||
assert toolchain.create_factory_bin() is False
|
||||
|
||||
|
||||
def test_missing_image_hint_names_the_flag(setup_core: Path) -> None:
|
||||
_setup_build(setup_core)
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
assert toolchain.missing_image_hint() is None # stock tree
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
|
||||
)
|
||||
assert "--skip-bootloader" in toolchain.missing_image_hint()
|
||||
|
||||
@@ -1548,6 +1548,7 @@ class MockArgs:
|
||||
partition_table: bool = False
|
||||
bootloader: bool = False
|
||||
states: bool | None = None
|
||||
device: list[str] | None = None
|
||||
|
||||
|
||||
def test_upload_program_serial_esp32(
|
||||
@@ -5357,6 +5358,28 @@ def _setup_build_info_test(
|
||||
return build_info_path, firmware_path
|
||||
|
||||
|
||||
def test_compile_program_warns_and_ignores_skip_bootloader_elsewhere(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A full build is always safe; orchestrators cannot see YAML overrides."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
|
||||
CORE.skip_bootloader = True
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.esp8266.check_rosetta",
|
||||
side_effect=EsphomeError("stop here"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="stop here"),
|
||||
caplog.at_level("INFO"),
|
||||
):
|
||||
compile_program(MockArgs(), config)
|
||||
|
||||
assert "--skip-bootloader ignored" in caplog.text
|
||||
assert CORE.skip_bootloader is False
|
||||
|
||||
|
||||
def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None:
|
||||
"""Test that compile_program runs the Rosetta preflight for ESP8266 targets."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
|
||||
@@ -6846,6 +6869,70 @@ def test_upload_using_esptool_arduino_toolchain(
|
||||
assert cmd_list[firmware_offset_idx + 1] == str(factory)
|
||||
|
||||
|
||||
def test_upload_using_esptool_skip_bootloader_tree_names_the_flag(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A serial flash needs the factory image the skip flag did not build."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"}
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{espidf_toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
|
||||
)
|
||||
config = {CONF_ESPHOME: {"platformio_options": {}}}
|
||||
with pytest.raises(EsphomeError, match="compiled with --skip-bootloader"):
|
||||
upload_using_esptool(config, "/dev/ttyUSB0", None, None)
|
||||
|
||||
|
||||
def test_command_run_rejects_serial_device_with_skip_bootloader(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The compile could never be flashed over serial; fail before it runs."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
|
||||
CORE.skip_bootloader = True
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
args = MockArgs(device=["/dev/ttyUSB0"])
|
||||
with patch("esphome.__main__.write_cpp") as mock_write:
|
||||
assert command_run(args, {}) == 1
|
||||
mock_write.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_ota_bootloader_skip_tree_names_the_flag(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""OTA --bootloader on a skip tree errors before picking the binary."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"}
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
build = CORE.relative_build_path("build")
|
||||
build.mkdir(parents=True)
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
(build / "CMakeCache.txt").write_text(
|
||||
f"{espidf_toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
|
||||
)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
"allow_partition_access": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
args = MockArgs(bootloader=True)
|
||||
with pytest.raises(EsphomeError, match="compiled with --skip-bootloader"):
|
||||
upload_program(config, args, ["192.168.1.100"])
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "pio_project_written"),
|
||||
[
|
||||
@@ -7073,6 +7160,23 @@ def test_compile_program_espidf_idedata_success_is_silent(
|
||||
assert "idedata" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_espidf_failed_factory_bin_fails_the_build() -> None:
|
||||
"""A compile whose factory image could not be produced must not exit 0."""
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin", return_value=False),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin") as mock_ota,
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 1
|
||||
mock_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_compile_program_espidf_idedata_none_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user