Compare commits

...
25 Commits
Author SHA1 Message Date
J. Nick Koston e4570b783d Merge branch 'esp32-pio-pch' into nrf52-pch 2026-09-03 23:48:54 +02:00
J. Nick Koston b027eb5cce Merge branch 'esp32-pio-pch' into nrf52-pch 2026-09-02 11:18:10 +02:00
J. Nick Koston f1f28c6969 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-30 14:57:41 -05:00
J. Nick Koston 4be9081349 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:52:40 -05:00
J. Nick Koston e3946c1871 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:45:04 -05:00
J. Nick Koston 9c8e560b7f Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 20:41:33 -05:00
J. Nick Koston a2f791e846 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 13:52:02 -05:00
J. Nick Koston 7ae325500e Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-28 13:07:07 -05:00
J. Nick Koston 33885bfddf Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 23:01:19 -05:00
J. Nick Koston 1f77384389 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 15:53:00 -05:00
J. Nick Koston 02761d72ab Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 14:04:03 -05:00
J. Nick Koston 52879b8a7f Skip the doomed gch compile after header-generation failure, self-heal the placeholder from CMake 2026-08-27 13:43:36 -05:00
J. Nick Koston 330adb9ba4 Cover the placeholder-touch failure path 2026-08-27 12:53:06 -05:00
J. Nick Koston e3c1d51f4f Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 12:46:19 -05:00
J. Nick Koston f180c95a65 Merge remote-tracking branch 'origin/nrf52-pch' into nrf52-pch 2026-08-27 12:42:50 -05:00
J. Nick Koston aa25b5fb0b Annotate the compile_ctx fixture 2026-08-27 12:42:36 -05:00
J. Nick Koston c8b54a33a9 Merge remote-tracking branch 'origin/nrf52-pch' into nrf52-pch 2026-08-27 12:39:40 -05:00
J. Nick Koston 17d1874ca3 Degrade on header-generation failure, surface probe and touch errors 2026-08-27 12:38:42 -05:00
J. Nick Koston 168c772e79 Merge branch 'esp32-pio-pch' into nrf52-pch 2026-08-27 12:38:39 -05:00
J. Nick Koston a638ac3982 Trim pch comments to repo standard, use partial for the prepare thunk 2026-08-27 11:05:10 -05:00
J. Nick Koston d45e0074a3 Share the guarded pch prepare contract, simplify wiring and tests 2026-08-27 11:01:46 -05:00
J. Nick Koston cf084bc70f Share the CMake pch consumer block, apply review suggestions 2026-08-27 10:53:26 -05:00
J. Nick Koston 21c1c582b5 Generate the Zephyr syscall headers before compiling the pch 2026-08-27 10:44:38 -05:00
J. Nick Koston 8defc48611 Resolve the sysbuild app domain dir for the pch 2026-08-27 10:34:17 -05:00
J. Nick Koston 10ea99d4c9 Add precompiled header support to the nRF52 sdk-nrf build 2026-08-27 10:25:45 -05:00
8 changed files with 554 additions and 50 deletions
+1 -2
View File
@@ -210,8 +210,7 @@ jobs:
- nrf52
- host
# Strict by default so a new matrix id cannot silently join in the
# degrade-quietly mode the knob exists to catch; the knob is inert
# where no pch code runs (nrf52).
# degrade-quietly mode the knob exists to catch.
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
include:
- id: bk72xx-arduino
+4 -29
View File
@@ -291,35 +291,10 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
def _pch_cmake() -> str:
"""The src component's precompiled-header block (C++ TUs only).
The -include stays relative (resolved from the compiler cwd, the build
dir); an absolute path would poison ccache keys.
"""
if not pch_enabled():
return ""
# Strict inverts: a per-process consumer rejection reds the build.
# Baked at generation: a knob flip takes effect when the CMakeLists is
# rewritten (every esphome compile); a hand-run idf.py keeps the old one
escalation = pch.pch_consumer_escalation()
return f"""
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
# OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop
# out of TU depfiles, and prepare_pch() touches the header on rebuild.
target_compile_options(${{COMPONENT_LIB}} PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
)
set_source_files_properties(${{app_sources}} PROPERTIES
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
"""
def discard_pch() -> None:
"""Drop the pch sidecars in the IDF build dir."""
pch.discard_pch(CORE.relative_build_path("build"))
"""Consumer block for the component CMakeLists. Baked at generation:
a strict-knob flip takes effect on the next esphome compile; a
hand-run idf.py keeps the old one."""
return pch.pch_cmake_consumer("${COMPONENT_LIB}", "${app_sources}")
def prepare_pch() -> None:
+51 -1
View File
@@ -7,7 +7,7 @@ it too; Arduino.h visibility there is intended (esphome#8693).
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
import hashlib
@@ -156,6 +156,32 @@ def pch_consumer_escalation() -> str:
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
def pch_cmake_consumer(target: str, sources_var: str) -> str:
"""Emit the CMake block making ``target``'s C++ sources consume the
pch; empty when disabled. OBJECT_DEPENDS is on the header, not the
.gch (pch-baked headers drop out of TU depfiles); the -include stays
relative — an absolute path would poison ccache keys."""
if not pch_enabled():
return ""
escalation = pch_consumer_escalation()
return f"""
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
# The touch keeps OBJECT_DEPENDS satisfiable when the build system itself
# wiped the build dir after the header was written (west --pristine)
if(NOT EXISTS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
file(TOUCH "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
endif()
target_compile_options({target} PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
)
set_source_files_properties({sources_var} PROPERTIES
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
"""
def ccache_pch_env() -> dict[str, str]:
"""Settings ccache needs to cache compiles that consume the .gch;
empty unless this build actually emitted one. User-set values win.
@@ -186,6 +212,30 @@ def ccache_pch_env() -> dict[str, str]:
return env
def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None:
"""Run a backend's pch preparation; an optional speedup must never
abort the build. Strict is read first so its own knob error cannot
mask the real failure; discard_pch raises if a stale .gch survives;
the header is ensured so OBJECT_DEPENDS stays satisfiable."""
try:
prepare()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
strict = pch_strict()
discard_pch(build_dir)
if strict:
raise
header = build_dir / PCH_HEADER_NAME
if not header.exists():
try:
header.touch()
except OSError as err:
# The coming OBJECT_DEPENDS error would hide the real cause
_LOGGER.warning("Could not create the pch placeholder: %s", err)
_LOGGER.warning(
"Precompiled header setup failed; compiling without it", exc_info=True
)
def pch_extra_scripts() -> list[str]:
"""The extra_scripts entries a PlatformIO platform registers for the
pch; empty when disabled (the script itself has no enable check)."""
+126
View File
@@ -1,13 +1,24 @@
from __future__ import annotations
import asyncio
from functools import partial
import logging
from pathlib import Path
import re
import shutil
import stat
import subprocess
from esphome import pins
from esphome.build_helpers import pch
from esphome.build_helpers.pch import (
PCH_DEFAULT_HEADERS,
PCH_HEADER_NAME,
mark_pch_emitted,
pch_cmake_consumer,
pch_enabled,
pch_header_text,
)
import esphome.codegen as cg
from esphome.components.zephyr import (
add_extra_script,
@@ -805,6 +816,9 @@ def _generate_cmake_lists() -> bool:
")",
]
if consumer := pch_cmake_consumer("app", "${APP_SOURCES}"):
lines += consumer.splitlines()
if link_flags:
lines += [
"",
@@ -819,6 +833,66 @@ def _generate_cmake_lists() -> bool:
)
def _app_build_dir(build_dir: Path) -> Path:
"""The CMake binary dir of the app image: sysbuild nests it in a
domain dir named after the app source dir. Probed on disk (the
non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays
truthful mid-build, unlike an SDK-version check."""
sysbuild_app = build_dir / "zephyr"
try:
cache = (sysbuild_app / "CMakeCache.txt").stat()
except (FileNotFoundError, NotADirectoryError):
return build_dir
# Other stat errors propagate; is_file() would silently mislocate the pch
return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir
def _prepare_pch(app_dir: Path) -> None:
"""Build the .gch between the cmake and compile phases of west."""
if not pch_enabled():
pch.discard_pch(app_dir)
pch.pch_disabled_degraded()
return
# First, so OBJECT_DEPENDS is satisfied even when the pch degrades
app_dir.mkdir(parents=True, exist_ok=True)
write_file_if_changed(
app_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS)
)
# New layout first (Zephyr >= 3.4 nests under zephyr/); fixed candidates
# keep the .sum identity deterministic and skip walking generated/
generated = app_dir / "zephyr" / "include" / "generated"
autoconf = None
for candidate in (generated / "zephyr" / "autoconf.h", generated / "autoconf.h"):
if candidate.exists():
autoconf = candidate
break
if autoconf is None:
# Fail closed: autoconf.h is the .sum's Kconfig identity
_LOGGER.warning("No autoconf.h found; compiling without the pch")
pch.discard_pch(app_dir)
pch.pch_degraded("autoconf.h missing")
return
try:
autoconf_text = autoconf.read_text(encoding="utf-8")
except OSError as err:
_LOGGER.warning(
"Could not read %s; compiling without the pch: %s", autoconf, err
)
pch.discard_pch(app_dir)
pch.pch_degraded(f"autoconf unreadable: {err}")
return
pch.prepare_pch(
app_dir,
PCH_DEFAULT_HEADERS,
(
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
zephyr_data()[KEY_BOARD],
autoconf_text,
*get_project_compile_flags(),
),
)
def _copy_if_exists(src: Path, dst: Path) -> None:
if src.is_file():
shutil.copy2(src, dst)
@@ -871,6 +945,58 @@ def run_compile(args, config: ConfigType) -> bool:
str(source_dir),
]
if pch_enabled():
# Consumers carry the -include; gate the ccache relaxation on it
# (Zephyr auto-enables ccache as the compiler launcher when found)
mark_pch_emitted()
env.update(pch.ccache_pch_env())
# Configure first so the .gch compiles from settled compile DB
# flags. Only when the app DB is missing: input changes wipe the
# build dir, so an existing DB is settled and --cmake-only would
# reconfigure for nothing.
prepare = True
app_dir = _app_build_dir(build_dir)
if not (app_dir / "compile_commands.json").is_file():
if not run_command_ok(
west_cmd + ["--cmake-only", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"],
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
raise EsphomeError("nRF52 native build configure failed")
# The configure phase creates the sysbuild domain dir: re-resolve
app_dir = _app_build_dir(build_dir)
# kernel.h needs the build-time syscall headers; under sysbuild
# the target exists only in the app domain's ninja
if not run_command_ok(
[
"cmake",
"--build",
str(app_dir),
"--target",
"zephyr_generated_headers",
],
env=env,
stream_output=True,
cwd=str(paths["framework_path"]),
):
# A pch-only prerequisite: degrade, let the real build report.
# Also skip the .gch compile: it would fail on the missing
# headers and latch .gch.failed until an identity change
_LOGGER.warning(
"Zephyr header generation failed; compiling without the pch"
)
pch.discard_pch(app_dir)
pch.pch_degraded("zephyr_generated_headers failed")
prepare = False
else:
prepare = True
app_dir = _app_build_dir(build_dir)
if prepare:
pch.guarded_prepare(app_dir, partial(_prepare_pch, app_dir))
if not run_command_ok(
west_cmd,
env=env,
+4 -17
View File
@@ -528,24 +528,11 @@ def run_compile(config, verbose: bool) -> int:
return result.returncode
_patch_memory_segments()
# After every reconfigure so compile_commands and sdkconfig are settled.
# An optional speedup must never abort the build
from esphome.build_gen.espidf import discard_pch, prepare_pch
# After every reconfigure so compile_commands and sdkconfig are settled
from esphome.build_gen.espidf import prepare_pch
from esphome.build_helpers.pch import guarded_prepare
try:
prepare_pch()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
from esphome.build_helpers.pch import pch_strict
# Strict first: its own knob error must not mask the real failure
strict = pch_strict()
# Raises itself if a stale .gch survives (silently wrong output)
discard_pch()
if strict:
raise
_LOGGER.warning(
"Precompiled header setup failed; compiling without it", exc_info=True
)
guarded_prepare(CORE.relative_build_path("build"), prepare_pch)
# Build
args = []
@@ -232,6 +232,51 @@ def test_pch_strict(
assert pch.pch_strict() is expected
def test_pch_cmake_consumer_substitutes_target_and_sources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
block = pch.pch_cmake_consumer("app", "${APP_SOURCES}")
assert "target_compile_options(app PRIVATE" in block
assert '"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"' in block
assert "-Wno-error=invalid-pch" in block
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in block
assert "set_source_files_properties(${APP_SOURCES} PROPERTIES" in block
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in block
# Placeholder guard: survives a build-system-side pristine wipe
assert 'file(TOUCH "${CMAKE_BINARY_DIR}/esphome_pch.h")' in block
def test_pch_cmake_consumer_strict_escalates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
assert "-Werror=invalid-pch" in pch.pch_cmake_consumer("app", "${APP_SOURCES}")
def test_pch_cmake_consumer_empty_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert pch.pch_cmake_consumer("app", "${APP_SOURCES}") == ""
def test_guarded_prepare_logs_placeholder_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed placeholder touch must be traceable, not silent."""
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
def boom() -> None:
raise RuntimeError("boom")
# Missing build dir: the touch raises and only warns
pch.guarded_prepare(tmp_path / "missing", boom)
assert "Could not create the pch placeholder" in caplog.text
def test_pch_degraded_raises_only_in_strict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -0,0 +1,322 @@
"""nrf52 sdk-nrf pch wiring: the CMake consumer block, the prepare wrapper,
and the two-phase west split in run_compile."""
from collections.abc import Generator
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from esphome.components import nrf52
from esphome.components.zephyr.const import KEY_BOARD
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def pch_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
@pytest.fixture
def build_dir(tmp_path: Path) -> Path:
d = tmp_path / "build" / ".pioenvs" / "livingroom"
d.mkdir(parents=True)
return d
_AUTOCONF_TEXT = "#define CONFIG_GPIO 1\n"
def _write_autoconf(build_dir: Path) -> Path:
autoconf = build_dir / "zephyr" / "include" / "generated" / "zephyr" / "autoconf.h"
autoconf.parent.mkdir(parents=True)
autoconf.write_text(_AUTOCONF_TEXT)
return autoconf
def test_prepare_pch_disabled_discards_and_degrades(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
gch = build_dir / "esphome_pch.h.gch"
gch.write_bytes(b"x")
nrf52._prepare_pch(build_dir)
assert not gch.exists()
def test_prepare_pch_disabled_strict_raises(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
nrf52._prepare_pch(build_dir)
def test_prepare_pch_missing_autoconf_degrades(
build_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
with patch.object(nrf52.pch, "prepare_pch") as prepare:
nrf52._prepare_pch(build_dir)
assert not prepare.called
assert "No autoconf.h found" in caplog.text
# The header is written first so OBJECT_DEPENDS stays satisfied
assert (build_dir / "esphome_pch.h").is_file()
def test_prepare_pch_missing_autoconf_strict_raises(
monkeypatch: pytest.MonkeyPatch, build_dir: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="autoconf.h missing"):
nrf52._prepare_pch(build_dir)
def test_prepare_pch_unreadable_autoconf_fails_closed(
build_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A directory named autoconf.h: read_text raises OSError
autoconf = build_dir / "zephyr" / "include" / "generated" / "autoconf.h"
autoconf.mkdir(parents=True)
with patch.object(nrf52.pch, "prepare_pch") as prepare:
nrf52._prepare_pch(build_dir)
assert not prepare.called
assert "Could not read" in caplog.text
def test_app_build_dir_sysbuild_layout(build_dir: Path) -> None:
app = build_dir / "zephyr"
app.mkdir()
(app / "CMakeCache.txt").write_text("")
assert nrf52._app_build_dir(build_dir) == app
def test_app_build_dir_top_level_layout(build_dir: Path) -> None:
# Non-sysbuild: build_dir/zephyr is the Zephyr output dir, no cache
(build_dir / "zephyr").mkdir()
assert nrf52._app_build_dir(build_dir) == build_dir
def test_app_build_dir_ignores_cache_directory(build_dir: Path) -> None:
(build_dir / "zephyr" / "CMakeCache.txt").mkdir(parents=True)
assert nrf52._app_build_dir(build_dir) == build_dir
def test_app_build_dir_propagates_stat_errors(build_dir: Path) -> None:
# is_file() would swallow this and mislocate the pch
with (
patch.object(Path, "stat", side_effect=PermissionError("denied")),
pytest.raises(PermissionError),
):
nrf52._app_build_dir(build_dir)
def test_prepare_pch_extras_carry_build_identity(build_dir: Path) -> None:
_write_autoconf(build_dir)
with (
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}),
patch.object(
nrf52, "zephyr_data", return_value={KEY_BOARD: "adafruit_feather"}
),
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
patch.object(nrf52.pch, "prepare_pch") as prepare,
):
nrf52._prepare_pch(build_dir)
assert (build_dir / "esphome_pch.h").is_file()
(passed_dir, headers, extras) = prepare.call_args.args
assert passed_dir == build_dir
assert headers == nrf52.PCH_DEFAULT_HEADERS
assert list(extras) == ["2.9.2", "adafruit_feather", _AUTOCONF_TEXT, "-Os"]
def _generate_cmake(tmp_path: Path) -> str:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
with (
patch(
"esphome.components.zephyr.library.generate_zephyr_modules",
return_value=[],
),
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
patch.object(nrf52, "get_project_link_flags", return_value=[]),
):
nrf52._generate_cmake_lists()
return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text()
def test_cmake_lists_include_pch_consumer_block(tmp_path: Path) -> None:
# Content contract is pinned by the shared pch_cmake_consumer tests;
# here only that the block reaches the generated CMakeLists
text = _generate_cmake(tmp_path)
assert "target_compile_options(app PRIVATE" in text
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in text
def test_cmake_lists_pch_block_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert "esphome_pch.h" not in _generate_cmake(tmp_path)
CompileCtx = tuple[Mock, Mock, Path]
class TestRunCompilePhases:
"""The pch pre-build block in run_compile: header write, conditional
cmake-only phase, and the never-abort-the-build exception contract."""
@pytest.fixture
def compile_ctx(self, tmp_path: Path) -> Generator[CompileCtx, None, None]:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
CORE.toolchain = Toolchain.SDK_NRF
with (
patch.object(nrf52, "check_and_install"),
patch.object(nrf52, "_generate_cmake_lists", return_value=False),
patch.object(
nrf52,
"get_build_paths",
return_value={
"python_executable": "python3",
"framework_path": tmp_path,
},
),
patch.object(nrf52, "get_build_env", return_value={}),
patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}),
patch.object(nrf52, "run_command_ok") as run_cmd,
patch.object(nrf52, "_prepare_pch") as prepare,
):
yield run_cmd, prepare, CORE.relative_pioenvs_path(CORE.name)
def _run(self) -> None:
nrf52.run_compile(None, {})
def test_missing_db_runs_cmake_phase(self, compile_ctx: CompileCtx) -> None:
run_cmd, prepare, build_dir = compile_ctx
# cmake-only ok, generated headers ok, final build fails
results = iter([True, True, False])
def west(cmd, **kwargs):
# Phase 1 configures the sysbuild app domain
app = build_dir / "zephyr"
app.mkdir(parents=True, exist_ok=True)
(app / "CMakeCache.txt").write_text("")
return next(results)
run_cmd.side_effect = west
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert "--cmake-only" in run_cmd.call_args_list[0].args[0]
# Generated syscall headers are built in the app domain pre-pch
headers_cmd = run_cmd.call_args_list[1].args[0]
assert headers_cmd[:2] == ["cmake", "--build"]
assert str(build_dir / "zephyr") in headers_cmd
assert "zephyr_generated_headers" in headers_cmd
assert "--cmake-only" not in run_cmd.call_args_list[2].args[0]
# The pch is prepared in the app domain dir, not the sysbuild root
assert prepare.call_args.args[0] == build_dir / "zephyr"
def test_generated_headers_failure_degrades(
self, compile_ctx, caplog: pytest.LogCaptureFixture
) -> None:
run_cmd, prepare, _ = compile_ctx
# headers target fails, the real build still runs (and fails here)
run_cmd.side_effect = [True, False, False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert "Zephyr header generation failed" in caplog.text
# The doomed .gch compile is skipped: it would latch .gch.failed
assert not prepare.called
def test_generated_headers_failure_strict_raises(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
run_cmd, prepare, _ = compile_ctx
run_cmd.side_effect = [True, False]
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
self._run()
assert not prepare.called
def test_cmake_phase_failure_raises(self, compile_ctx: CompileCtx) -> None:
run_cmd, prepare, _ = compile_ctx
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="configure failed"):
self._run()
assert not prepare.called
def test_ccache_pch_env_reaches_west(self, compile_ctx: CompileCtx) -> None:
run_cmd, _, _ = compile_ctx
run_cmd.side_effect = [False]
# clear=True also drops ambient CCACHE_*/ESPHOME_PCH_* overrides
with (
patch.dict("os.environ", {}, clear=True),
pytest.raises(EsphomeError, match="configure failed"),
):
self._run()
env = run_cmd.call_args.kwargs["env"]
assert env["CCACHE_PCH_EXTSUM"] == "true"
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
@pytest.mark.parametrize("sysbuild", [False, True])
def test_settled_db_skips_cmake_phase(
self, sysbuild: bool, compile_ctx: CompileCtx
) -> None:
run_cmd, prepare, build_dir = compile_ctx
app = build_dir / "zephyr" if sysbuild else build_dir
app.mkdir(parents=True)
# A present top-level cache keeps the pristine wipe from dropping
# the DB; the app-dir cache is the sysbuild layout marker
(build_dir / "CMakeCache.txt").write_text("")
(app / "CMakeCache.txt").write_text("")
(app / "compile_commands.json").write_text("[]")
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 1
assert "--cmake-only" not in run_cmd.call_args.args[0]
assert prepare.call_args.args[0] == app
def test_disabled_skips_header_and_cmake_phase(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
run_cmd, prepare, build_dir = compile_ctx
run_cmd.side_effect = [False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 1
assert not (build_dir / "esphome_pch.h").exists()
# The wrapper still runs: it discards stale sidecars and feeds strict
assert prepare.called
def test_prepare_failure_never_aborts_the_build(
self, compile_ctx, caplog: pytest.LogCaptureFixture
) -> None:
run_cmd, prepare, build_dir = compile_ctx
build_dir.mkdir(parents=True)
# Keep the pristine wipe from dropping the dir the fallback touches
(build_dir / "CMakeCache.txt").write_text("")
prepare.side_effect = RuntimeError("boom")
run_cmd.side_effect = [True, True, False]
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
self._run()
assert run_cmd.call_count == 3
assert "Precompiled header setup failed" in caplog.text
# The fallback still satisfies OBJECT_DEPENDS
assert (build_dir / "esphome_pch.h").is_file()
def test_prepare_failure_strict_raises(
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
) -> None:
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
run_cmd, prepare, _ = compile_ctx
prepare.side_effect = RuntimeError("boom")
run_cmd.side_effect = [True, True]
with pytest.raises(RuntimeError, match="boom"):
self._run()
+1 -1
View File
@@ -684,7 +684,7 @@ def test_run_compile_aborts_when_stale_pch_survives_discard(
patch.object(toolchain, "print_summary"),
patch("esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")),
patch(
"esphome.build_gen.espidf.discard_pch",
"esphome.build_helpers.pch.discard_pch",
side_effect=EsphomeError("Could not discard the stale precompiled header"),
),
pytest.raises(EsphomeError, match="Could not discard"),