Add precompiled header support to the nRF52 sdk-nrf build

This commit is contained in:
J. Nick Koston
2026-08-27 10:25:45 -05:00
parent a4136c8a17
commit 10ea99d4c9
3 changed files with 337 additions and 2 deletions
+1 -2
View File
@@ -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
+99
View File
@@ -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",
' "$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"',
f' "$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"',
' "$<$<COMPILE_LANGUAGE:CXX>:-include>"',
f' "$<$<COMPILE_LANGUAGE:CXX>:{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,
@@ -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 '"$<$<COMPILE_LANGUAGE:CXX>: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()