[nrf52] add support for native builds (#16898)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
tomaszduda23
2026-06-18 03:17:07 +02:00
committed by GitHub
parent c9095841ae
commit e3f164fff2
8 changed files with 369 additions and 41 deletions

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from esphome.components.esp32 import get_esp32_variant, idf_version from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.core import CORE from esphome.core import CORE
from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags
from esphome.helpers import mkdir_p, write_file_if_changed from esphome.helpers import mkdir_p, write_file_if_changed
# Replaces the IDF default C++ standard (-std=gnu++2b appended to # Replaces the IDF default C++ standard (-std=gnu++2b appended to
@@ -84,12 +85,7 @@ def get_project_cmakelists(minimal: bool = False) -> str:
# esphome__micro-mp3) rather than just src/. Required so suppressions # esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author. # third-party components we don't author.
project_compile_opts = [ project_compile_opts = get_project_compile_flags()
flag
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
extra_compile_options = "\n".join( extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts for flag in project_compile_opts
@@ -188,8 +184,8 @@ def get_component_cmakelists() -> str:
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are # Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in # emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/. # get_project_cmakelists so they reach every component, not just src/.
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] link_opts = get_project_link_flags()
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" link_opts_str = "\n ".join(link_opts) if link_opts else ""
return f"""\ return f"""\
# Auto-generated by ESPHome # Auto-generated by ESPHome

View File

@@ -52,6 +52,11 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.core.config import BOARD_MAX_LENGTH from esphome.core.config import BOARD_MAX_LENGTH
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.framework_helpers import (
get_project_compile_flags,
get_project_link_flags,
run_command_ok,
)
from esphome.helpers import write_file_if_changed from esphome.helpers import write_file_if_changed
from esphome.storage_json import StorageJSON from esphome.storage_json import StorageJSON
from esphome.types import ConfigType from esphome.types import ConfigType
@@ -63,7 +68,7 @@ from .const import (
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
) )
from .framework import check_and_install from .framework import check_and_install, get_build_env, get_build_paths
# force import gpio to register pin schema # force import gpio to register pin schema
from .gpio import nrf52_pin_to_code # noqa: F401 from .gpio import nrf52_pin_to_code # noqa: F401
@@ -99,9 +104,6 @@ FAKE_BOARD_MANIFEST = """
def set_core_data(config: ConfigType) -> ConfigType: def set_core_data(config: ConfigType) -> ConfigType:
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
if CORE.toolchain is None:
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
zephyr_set_core_data(config) zephyr_set_core_data(config)
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR
@@ -112,6 +114,12 @@ def set_core_data(config: ConfigType) -> ConfigType:
return config return config
def _resolve_toolchain(config: ConfigType) -> ConfigType:
if CORE.toolchain is None:
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
return config
def set_framework(config: ConfigType) -> ConfigType: def set_framework(config: ConfigType) -> ConfigType:
if CONF_VERSION not in config[CONF_FRAMEWORK]: if CONF_VERSION not in config[CONF_FRAMEWORK]:
default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2"
@@ -147,6 +155,12 @@ BOOTLOADERS = [
] ]
def _validate_toolchain(value) -> Toolchain:
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
)
def _detect_bootloader(config: ConfigType) -> ConfigType: def _detect_bootloader(config: ConfigType) -> ConfigType:
"""Detect the bootloader for the given board.""" """Detect the bootloader for the given board."""
config = config.copy() config = config.copy()
@@ -233,9 +247,11 @@ CONFIG_SCHEMA = cv.All(
), ),
} }
), ),
cv.Optional(CONF_TOOLCHAIN): _validate_toolchain,
cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm),
} }
), ),
_resolve_toolchain,
set_framework, set_framework,
) )
@@ -565,6 +581,47 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
return False return False
def _generate_cmake_lists() -> None:
compile_flags = get_project_compile_flags()
link_flags = get_project_link_flags()
lines = [
"cmake_minimum_required(VERSION 3.20.0)",
"",
'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")',
"",
"find_package(Zephyr REQUIRED)",
"",
f"project({CORE.name})",
"",
'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")',
"",
"target_sources(app PRIVATE ${APP_SOURCES})",
'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")',
]
if compile_flags:
lines += [
"",
"target_compile_options(app PRIVATE",
*[f' "{flag}"' for flag in compile_flags],
")",
]
if link_flags:
lines += [
"",
"zephyr_ld_options(",
*[f' "{flag}"' for flag in link_flags],
")",
]
write_file_if_changed(
CORE.relative_build_path("zephyr", "CMakeLists.txt"),
"\n".join(lines) + "\n",
)
def run_compile(args, config: ConfigType) -> bool: def run_compile(args, config: ConfigType) -> bool:
if CORE.using_toolchain_platformio: if CORE.using_toolchain_platformio:
return False return False
@@ -574,4 +631,35 @@ def run_compile(args, config: ConfigType) -> bool:
"Supported toolchains are 'platformio' and 'sdk-nrf'." "Supported toolchains are 'platformio' and 'sdk-nrf'."
) )
check_and_install() check_and_install()
raise EsphomeError("Native build for nRF52 is not implemented yet")
paths = get_build_paths()
env = get_build_env()
_generate_cmake_lists()
board = zephyr_data()[KEY_BOARD]
build_dir = CORE.relative_pioenvs_path(CORE.name)
source_dir = CORE.relative_build_path("zephyr")
west_cmd = [
str(paths["python_executable"]),
"-m",
"west",
"build",
"--pristine=auto",
"-b",
board,
"-d",
str(build_dir),
str(source_dir),
]
if not run_command_ok(
west_cmd,
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
raise EsphomeError("nRF52 native build failed")
return True

View File

@@ -18,7 +18,7 @@ from esphome.framework_helpers import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_WEST_VERSION = "1.5.0" _REQUIREMENTS = Path(__file__).parent / "requirements.txt"
_TOOLCHAIN_VERSION = "0.17.4" _TOOLCHAIN_VERSION = "0.17.4"
SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
@@ -28,6 +28,15 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
) )
) )
# Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and
# host tools (dtc etc.) required by the Zephyr cmake build system.
SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str(
os.environ.get(
"ESPHOME_SDK_NG_MINIMAL_MIRRORS",
"https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/zephyr-sdk-{VERSION}_{sysname}-{machine}_minimal.{extension}",
)
)
def _get_tools_path() -> Path: def _get_tools_path() -> Path:
return CORE.data_dir / "sdk-nrf" return CORE.data_dir / "sdk-nrf"
@@ -38,11 +47,11 @@ def _get_python_env_path(version: str) -> Path:
def _get_framework_path(version: str) -> Path: def _get_framework_path(version: str) -> Path:
return _get_tools_path() / "frameworks" / f"{version}" return _get_tools_path() / "frameworks" / version
def _get_toolchain_path(version: str) -> Path: def _get_toolchain_path(version: str) -> Path:
return _get_tools_path() / "toolchains" / f"{version}" return _get_tools_path() / "toolchains" / version
# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. # onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror.
@@ -95,29 +104,68 @@ def _get_toolchain_platform_info() -> tuple[str, str, str]:
return sysname, machine, extension return sysname, machine, extension
def check_and_install() -> None: def _get_version_str() -> str:
framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" return f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}"
def get_build_paths() -> dict:
version = _get_version_str()
return {
"python_executable": get_python_env_executable_path(
_get_python_env_path(version), "python"
),
"framework_path": _get_framework_path(version),
}
def get_build_env() -> dict:
version = _get_version_str()
venv_bin_dir = get_python_env_executable_path(
_get_python_env_path(version), "python"
).parent
env = os.environ.copy()
env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "")
env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr")
env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake")
return env
def check_and_install() -> None:
version = _get_version_str()
python_env_path = _get_python_env_path(version) python_env_path = _get_python_env_path(version)
env_python_path = get_python_env_executable_path(python_env_path, "python") env_python_path = get_python_env_executable_path(python_env_path, "python")
sentinel = python_env_path / ".ready" sentinel = python_env_path / ".ready"
install_venv = not sentinel.exists() install_venv = (
not sentinel.exists()
or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime
)
if install_venv: if install_venv:
rmdir(python_env_path, msg=f"Clean up {version} Python environment") rmdir(python_env_path, msg=f"Clean up {version} Python environment")
create_venv(python_env_path, msg=f"{version}") create_venv(python_env_path, msg=version)
_install_sitecustomize(python_env_path) _install_sitecustomize(python_env_path)
_LOGGER.info("Installing west %s ...", _WEST_VERSION) _LOGGER.info("Installing requirements ...")
cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] cmd = [
str(env_python_path),
"-m",
"pip",
"install",
"-r",
str(_REQUIREMENTS),
]
if not run_command_ok(cmd): if not run_command_ok(cmd):
raise EsphomeError(f"Install west for {version} Python environment failure") raise EsphomeError(
f"Install requirements for {version} Python environment failure"
)
sentinel.touch() sentinel.touch()
framework_path = _get_framework_path(version) framework_path = _get_framework_path(version)
sentinel = framework_path / ".ready" sentinel = framework_path / ".ready"
if install_venv or not sentinel.exists(): zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt"
if not sentinel.exists() or not zephyr_reqs.exists():
rmdir(framework_path, msg=f"Clean up {version} framework environment") rmdir(framework_path, msg=f"Clean up {version} framework environment")
_LOGGER.info("Initializing nRF Connect SDK %s ...", version) _LOGGER.info("Initializing nRF Connect SDK %s ...", version)
cmd = [ cmd = [
@@ -128,7 +176,7 @@ def check_and_install() -> None:
"-m", "-m",
"https://github.com/nrfconnect/sdk-nrf", "https://github.com/nrfconnect/sdk-nrf",
"--mr", "--mr",
f"{version}", version,
str(framework_path), str(framework_path),
] ]
if not run_command_ok(cmd): if not run_command_ok(cmd):
@@ -146,17 +194,47 @@ def check_and_install() -> None:
raise EsphomeError(f"Can't update nRF Connect SDK {version}") raise EsphomeError(f"Can't update nRF Connect SDK {version}")
sentinel.touch() sentinel.touch()
zephyr_sentinel = python_env_path / ".zephyr_reqs_ready"
if (
install_venv
or not zephyr_sentinel.exists()
or zephyr_reqs.stat().st_mtime > zephyr_sentinel.stat().st_mtime
):
_LOGGER.info("Installing Zephyr requirements ...")
cmd = [
str(env_python_path),
"-m",
"pip",
"install",
"-r",
str(zephyr_reqs),
]
if not run_command_ok(cmd):
raise EsphomeError(f"Install Zephyr requirements for {version} failure")
zephyr_sentinel.touch()
toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION)
sentinel = toolchains_dir / ".ready" sentinel = toolchains_dir / ".ready"
if not sentinel.exists(): if not sentinel.exists():
rmdir( rmdir(
toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment"
) )
sysname, machine, extension = _get_toolchain_platform_info()
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION)
download_from_mirrors(
SDK_NG_MINIMAL_MIRRORS,
{
"VERSION": _TOOLCHAIN_VERSION,
"sysname": sysname,
"machine": machine,
"extension": extension,
},
tmp.file,
)
archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting")
with tempfile.NamedTemporaryFile() as tmp: with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION)
sysname, machine, extension = _get_toolchain_platform_info()
download_from_mirrors( download_from_mirrors(
SDK_NG_TOOLCHAIN_MIRRORS, SDK_NG_TOOLCHAIN_MIRRORS,
{ {
@@ -167,5 +245,9 @@ def check_and_install() -> None:
}, },
tmp.file, tmp.file,
) )
archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") archive_extract_all(
tmp.file,
toolchains_dir / "arm-zephyr-eabi",
progress_header="Extracting",
)
sentinel.touch() sentinel.touch()

View File

@@ -0,0 +1,3 @@
west==1.5.0
ninja==1.13.0
cmake==4.3.2

View File

@@ -20,6 +20,25 @@ PathType = str | os.PathLike
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def get_project_link_flags() -> list[str]:
"""Return the sorted -Wl, linker flags from the current build."""
from esphome.core import CORE # local import to avoid circular dependency
return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,"))
def get_project_compile_flags() -> list[str]:
"""Return the sorted -D and -W (non-linker) flags from the current build."""
from esphome.core import CORE # local import to avoid circular dependency
return [
flag
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
def str_to_lst_of_str(a: str | list[str]) -> list[str]: def str_to_lst_of_str(a: str | list[str]) -> list[str]:
""" """
Convert a string to a list of string Convert a string to a list of string

View File

@@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property(
assert "JPEGDEC APPEND" not in content assert "JPEGDEC APPEND" not in content
def test_get_component_cmakelists_no_link_flags() -> None:
"""With no -Wl, flags the target_link_options block is emitted with an empty body."""
CORE.build_flags = set()
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content
def test_get_component_cmakelists_single_link_flag() -> None:
"""A single -Wl, flag appears indented inside target_link_options."""
CORE.build_flags = {"-Wl,--gc-sections"}
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert (
"target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)"
in content
)
def test_get_component_cmakelists_multiple_link_flags_sorted() -> None:
"""Multiple -Wl, flags are sorted and joined with the four-space indent."""
CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"}
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
expected = (
"target_link_options(${COMPONENT_LIB} PUBLIC\n"
" -Wl,--gc-sections\n"
" -Wl,-Map=out.map\n"
" -Wl,-z,noexecstack\n"
")"
)
assert expected in content
def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None:
"""-D and -W (non-linker) flags must not appear in target_link_options."""
CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"}
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
assert "-DFOO" not in content.split("target_link_options")[1]
assert "-Wall" not in content.split("target_link_options")[1]
assert "-Wl,--gc-sections" in content
def test_get_project_cmakelists_emits_managed_components_property( def test_get_project_cmakelists_emits_managed_components_property(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:

View File

@@ -25,6 +25,8 @@ from esphome.framework_helpers import (
archive_extract_all, archive_extract_all,
create_venv, create_venv,
download_from_mirrors, download_from_mirrors,
get_project_compile_flags,
get_project_link_flags,
get_python_env_executable_path, get_python_env_executable_path,
get_system_python_path, get_system_python_path,
rmdir, rmdir,
@@ -952,3 +954,84 @@ class TestSevenZipExtractAll:
out.mkdir() out.mkdir()
archive_extract_all(archive, out) archive_extract_all(archive, out)
assert (out / "hello.txt").exists() assert (out / "hello.txt").exists()
# ---------------------------------------------------------------------------
# get_project_compile_flags / get_project_link_flags
# ---------------------------------------------------------------------------
def _make_core(flags: set[str]):
core = MagicMock()
core.build_flags = flags
return core
class TestGetProjectCompileFlags:
def test_returns_define_flags(self) -> None:
with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})):
assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"]
def test_returns_warning_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-Wno-error", "-Wall"}),
):
assert get_project_compile_flags() == ["-Wall", "-Wno-error"]
def test_excludes_linker_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}),
):
assert get_project_compile_flags() == ["-DFOO"]
def test_excludes_other_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-O2", "-std=gnu++20", "-DFOO"}),
):
assert get_project_compile_flags() == ["-DFOO"]
def test_empty_build_flags(self) -> None:
with patch("esphome.core.CORE", _make_core(set())):
assert get_project_compile_flags() == []
def test_result_is_sorted(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}),
):
result = get_project_compile_flags()
assert result == sorted(result)
class TestGetProjectLinkFlags:
def test_returns_linker_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}),
):
assert get_project_link_flags() == [
"-Wl,--gc-sections",
"-Wl,-Map=output.map",
]
def test_excludes_compile_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}),
):
assert get_project_link_flags() == ["-Wl,--gc-sections"]
def test_empty_build_flags(self) -> None:
with patch("esphome.core.CORE", _make_core(set())):
assert get_project_link_flags() == []
def test_result_is_sorted(self) -> None:
with patch(
"esphome.core.CORE",
_make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}),
):
result = get_project_link_flags()
assert result == sorted(result)

View File

@@ -58,6 +58,9 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace:
toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION
for d in (python_env, framework, toolchain_dir): for d in (python_env, framework, toolchain_dir):
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
zephyr_scripts = framework / "zephyr" / "scripts"
zephyr_scripts.mkdir(parents=True, exist_ok=True)
(zephyr_scripts / "requirements.txt").touch()
return SimpleNamespace( return SimpleNamespace(
python_env=python_env, python_env=python_env,
framework=framework, framework=framework,
@@ -102,6 +105,7 @@ class TestCheckAndInstall:
) -> None: ) -> None:
"""All three sentinels present → nothing downloaded or compiled.""" """All three sentinels present → nothing downloaded or compiled."""
(nrf52_dirs.python_env / ".ready").touch() (nrf52_dirs.python_env / ".ready").touch()
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.framework / ".ready").touch()
(nrf52_dirs.toolchain / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch()
@@ -121,11 +125,13 @@ class TestCheckAndInstall:
check_and_install() check_and_install()
mock_nrf52_ops.create_venv.assert_called_once() mock_nrf52_ops.create_venv.assert_called_once()
# pip install west, west init, west update # pip install requirements, west init, west update, pip install zephyr reqs
assert mock_nrf52_ops.run_command_ok.call_count == 3 assert mock_nrf52_ops.run_command_ok.call_count == 4
mock_nrf52_ops.download_from_mirrors.assert_called_once() # minimal SDK + per-arch toolchain
mock_nrf52_ops.archive_extract_all.assert_called_once() assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
assert (nrf52_dirs.python_env / ".ready").exists() assert (nrf52_dirs.python_env / ".ready").exists()
assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists()
assert (nrf52_dirs.framework / ".ready").exists() assert (nrf52_dirs.framework / ".ready").exists()
assert (nrf52_dirs.toolchain / ".ready").exists() assert (nrf52_dirs.toolchain / ".ready").exists()
@@ -140,9 +146,10 @@ class TestCheckAndInstall:
check_and_install() check_and_install()
mock_nrf52_ops.create_venv.assert_not_called() mock_nrf52_ops.create_venv.assert_not_called()
# west init + west update only (no pip install) # west init, west update, pip install zephyr reqs
assert mock_nrf52_ops.run_command_ok.call_count == 2 assert mock_nrf52_ops.run_command_ok.call_count == 3
mock_nrf52_ops.download_from_mirrors.assert_called_once() # minimal SDK + per-arch toolchain
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
def test_toolchain_only_missing( def test_toolchain_only_missing(
self, self,
@@ -151,24 +158,26 @@ class TestCheckAndInstall:
) -> None: ) -> None:
"""Venv and framework ready → only toolchain downloaded and extracted.""" """Venv and framework ready → only toolchain downloaded and extracted."""
(nrf52_dirs.python_env / ".ready").touch() (nrf52_dirs.python_env / ".ready").touch()
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.framework / ".ready").touch()
check_and_install() check_and_install()
mock_nrf52_ops.create_venv.assert_not_called() mock_nrf52_ops.create_venv.assert_not_called()
mock_nrf52_ops.run_command_ok.assert_not_called() mock_nrf52_ops.run_command_ok.assert_not_called()
mock_nrf52_ops.download_from_mirrors.assert_called_once() # minimal SDK + per-arch toolchain
mock_nrf52_ops.archive_extract_all.assert_called_once() assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
def test_west_install_failure_raises( def test_requirements_install_failure_raises(
self, self,
nrf52_dirs: SimpleNamespace, nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace, mock_nrf52_ops: SimpleNamespace,
) -> None: ) -> None:
"""Failing pip install west raises EsphomeError.""" """Failing pip install -r requirements.txt raises EsphomeError."""
mock_nrf52_ops.run_command_ok.return_value = False mock_nrf52_ops.run_command_ok.return_value = False
with pytest.raises(EsphomeError, match="Install west"): with pytest.raises(EsphomeError, match="Install requirements"):
check_and_install() check_and_install()
def test_framework_init_failure_raises( def test_framework_init_failure_raises(