diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5c0abf82ea..2fcd41164d 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -204,8 +204,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 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index aeeaba0c11..0b68e7b02d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,6 +8,15 @@ import shutil 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_consumer_escalation, + pch_enabled, + pch_header_text, +) import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -805,6 +814,25 @@ def _generate_cmake_lists() -> bool: ")", ] + if pch_enabled(): + # 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. The relative -include resolves from the compiler cwd + # (the build dir); an absolute path would poison ccache keys. + escalation = pch_consumer_escalation() + lines += [ + "", + "target_compile_options(app PRIVATE", + ' "$<$:-Winvalid-pch>"', + f' "$<$:{escalation}>"', + ' "$<$:-include>"', + f' "$<$:{PCH_HEADER_NAME}>"', + ")", + "set_source_files_properties(${APP_SOURCES} PROPERTIES", + f' OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")', + ] + if link_flags: lines += [ "", @@ -819,6 +847,40 @@ def _generate_cmake_lists() -> bool: ) +def _prepare_pch(build_dir: Path) -> None: + """Build the .gch between the cmake and compile phases of west.""" + if not pch_enabled(): + pch.discard_pch(build_dir) + pch.pch_disabled_degraded() + return + autoconf = next(build_dir.glob("zephyr/include/generated/**/autoconf.h"), None) + 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(build_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(build_dir) + pch.pch_degraded(f"autoconf unreadable: {err}") + return + pch.prepare_pch( + build_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 +933,43 @@ def run_compile(args, config: ConfigType) -> bool: str(source_dir), ] + if pch_enabled(): + # Before the cmake phase: OBJECT_DEPENDS names the header + build_dir.mkdir(parents=True, exist_ok=True) + write_file_if_changed( + build_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS) + ) + # 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()) + + # Split west into configure + build so the .gch is compiled from the + # settled compile_commands.json flags between the two phases. Only + # when the DB is missing: any input change wipes the build dir, so + # an existing DB is settled, and --cmake-only always reconfigures + if not (build_dir / "compile_commands.json").is_file() and 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 failed") + + # An optional speedup must never abort the build + try: + _prepare_pch(build_dir) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Strict first: its own knob error must not mask the real failure + strict = pch.pch_strict() + # Raises itself if a stale .gch survives (silently wrong output) + pch.discard_pch(build_dir) + if strict: + raise + _LOGGER.warning( + "Precompiled header setup failed; compiling without it", exc_info=True + ) + if not run_command_ok( west_cmd, env=env, diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py new file mode 100644 index 0000000000..36539d91a6 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -0,0 +1,237 @@ +"""nrf52 sdk-nrf pch wiring: the CMake consumer block, the prepare wrapper, +and the two-phase west split in run_compile.""" + +from pathlib import Path +from unittest.mock import 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 + + +def _write_autoconf(build_dir: Path, text: str = "#define CONFIG_GPIO 1\n") -> Path: + autoconf = build_dir / "zephyr" / "include" / "generated" / "zephyr" / "autoconf.h" + autoconf.parent.mkdir(parents=True) + autoconf.write_text(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 + + +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_prepare_pch_extras_carry_build_identity(build_dir: Path) -> None: + _write_autoconf(build_dir, "#define CONFIG_GPIO 1\n") + 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) + (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", + "#define CONFIG_GPIO 1\n", + "-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_pch_block_default(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path) + assert "-Winvalid-pch" in text + assert "-Wno-error=invalid-pch" in text + # Relative -include; the only build-dir reference is the OBJECT_DEPENDS + assert '"$<$:esphome_pch.h>"' in text + assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in text + + +def test_cmake_lists_pch_block_strict_escalates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + assert "-Werror=invalid-pch" in _generate_cmake(tmp_path) + + +def test_cmake_lists_pch_block_disabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert "invalid-pch" not in _generate_cmake(tmp_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): + 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) -> None: + run_cmd, prepare, build_dir = compile_ctx + run_cmd.side_effect = [True, False] # cmake-only ok, final build fails + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + self._run() + assert "--cmake-only" in run_cmd.call_args_list[0].args[0] + assert "--cmake-only" not in run_cmd.call_args_list[1].args[0] + assert prepare.called + assert (build_dir / "esphome_pch.h").is_file() + + def test_cmake_phase_failure_raises(self, compile_ctx) -> None: + run_cmd, prepare, _ = compile_ctx + run_cmd.side_effect = [False] + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + self._run() + assert not prepare.called + + def test_settled_db_skips_cmake_phase(self, compile_ctx) -> None: + run_cmd, prepare, build_dir = compile_ctx + build_dir.mkdir(parents=True) + # A present cache keeps the pristine wipe from dropping the DB + (build_dir / "CMakeCache.txt").write_text("") + (build_dir / "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.called + + def test_disabled_skips_header_and_cmake_phase( + self, monkeypatch: pytest.MonkeyPatch, compile_ctx + ) -> 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, _ = compile_ctx + prepare.side_effect = RuntimeError("boom") + run_cmd.side_effect = [True, False] + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + self._run() + assert run_cmd.call_count == 2 + assert "Precompiled header setup failed" in caplog.text + + def test_prepare_failure_strict_raises( + self, monkeypatch: pytest.MonkeyPatch, compile_ctx + ) -> None: + monkeypatch.setenv("ESPHOME_PCH_STRICT", "1") + run_cmd, prepare, _ = compile_ctx + prepare.side_effect = RuntimeError("boom") + run_cmd.side_effect = [True] + with pytest.raises(RuntimeError, match="boom"): + self._run()