mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
Add precompiled header support to the nRF52 sdk-nrf build
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user