From 18eb05177b41ab85bcaf9e0373ea50b58787f5cb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:30:53 +0000 Subject: [PATCH 1/3] apply automatic formatting fixes --- tests/unit_tests/test_platformio_pch_script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index ae3ad91099..8edb6be397 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -56,7 +56,9 @@ class _FakeSConsEnv(dict): def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: """A compiler stand-in that records its argv and writes the -o target.""" cxx = tmp_path / "fake-gxx" - body = 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + body = ( + 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + ) if fail: body += "echo boom >&2\nexit 1\n" else: From d08c524f19e35190c10477dee986977c2ef0fb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:36:41 -0500 Subject: [PATCH 2/3] Harden the pch script for Windows paths and make ignored pchs visible --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_helpers/ccache.py | 6 +++- esphome/build_helpers/pch.py | 4 ++- esphome/platformio/pch.py.script | 34 +++++++++++++------ esphome/writer.py | 11 ++++++ .../unit_tests/build_gen/test_arduino8266.py | 2 +- .../unit_tests/test_platformio_pch_script.py | 2 +- 7 files changed, 45 insertions(+), 16 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 1f20ec7b06..1311152bde 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1254,7 +1254,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # Relative -include (resolved from the ninja cwd, where the header # lives): an absolute path would put the per-device build path on # every compile command and defeat cross-device ccache sharing - cxx_parts = src_other + [f"-include {PCH_HEADER_NAME}"] + cxx_parts = src_other + [f"-Winvalid-pch -include {PCH_HEADER_NAME}"] lines.append(f"srccxxflags = {' '.join(cxx_parts)}") src_cxx_flags = "$srccxxflags" src_cxx_implicit = gch diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 99d1bbc111..cee0774561 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -97,4 +97,8 @@ def effective_ccache_basedir() -> str: wins, else the resolved build path (matching ccache_defaults_env).""" from esphome.core import CORE - return os.environ.get("CCACHE_BASEDIR") or str(Path(CORE.build_path).resolve()) + raw = os.environ.get("CCACHE_BASEDIR") + if raw is not None: + # An explicitly empty value disables ccache's rewriting; mirror it + return raw + return str(Path(CORE.build_path).resolve()) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 10761cdded..0b691c06a4 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -2,7 +2,9 @@ Safe by construction when the prefix header mirrors what the TUs already include first (ESP8266); a backend may instead inject a curated set of -self-contained core headers (ESP-IDF). +self-contained core headers (ESP-IDF). User sources from ``esphome: +includes:`` also receive the prefix, so they now see defines.h (and +Arduino.h on Arduino platforms) even when they did not include it. """ from __future__ import annotations diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index bb7d35708b..6e0ba70ddc 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -50,11 +50,13 @@ def _include_closure(src_dir: Path, roots: list) -> dict: def _shell_arg(element) -> str: """One compiler argv from one SCons element, matching the real spawn: - SCons whole-quotes spaced elements, the shell unquotes the rest.""" + SCons whole-quotes spaced elements, the shell unquotes the rest. On + Windows there is no POSIX shell pass and shlex would eat path + backslashes.""" arg = str(element) - if " " in arg: + if " " in arg or os.name == "nt": return arg.replace('\\"', '"') - return shlex.split(arg)[0] if arg else arg + return shlex.split(arg)[0] if arg.strip() else arg def _setup_pch() -> None: @@ -104,10 +106,13 @@ def _setup_pch() -> None: for package in sorted(platform.packages): try: version = platform.get_package_version(package) - except Exception as err: # noqa: BLE001 -- absent optional package - # Folded into the digest so an unexpected lookup failure still - # invalidates instead of hashing like a fixed absence - version = f"error:{type(err).__name__}" + except KeyError: + version = None # absent optional package + except Exception as err: # noqa: BLE001 + # Without trustworthy package identity a stale .gch could be + # reused across upgrades; skip the pch instead + print(f"ESPHome: skipping precompiled header: {err}") + return digest.update(f"{package}={version}".encode()) digest.update(b"\0") closure = _include_closure(src_dir, [*include_headers, _CORE_HEADER]) @@ -133,9 +138,13 @@ def _setup_pch() -> None: and not inc_dir.is_relative_to(src_dir) ): continue - for local in sorted(inc_dir.rglob("*.h")): + for local in sorted(p for p in inc_dir.rglob("*") if p.is_file()): + try: + data = local.read_bytes() + except OSError: + data = b"" digest.update(str(local.relative_to(proj_dir)).encode()) - digest.update(local.read_bytes()) + digest.update(data) digest.update(b"\0") checksum = digest.hexdigest() @@ -215,8 +224,11 @@ def _setup_pch() -> None: # Prepended so it is processed before the build_src_flags -include # entries: GCC only uses a .gch while no other tokens have been seen. - projenv.Prepend(CXXFLAGS=["-include", header.name]) # noqa: F821 + projenv.Prepend(CXXFLAGS=["-Winvalid-pch", "-include", header.name]) # noqa: F821 print("ESPHome: Compiling with precompiled header") -_setup_pch() +try: + _setup_pch() +except Exception as err: # noqa: BLE001 -- a speedup must never break the build + print(f"ESPHome: precompiled header setup failed; compiling without it: {err}") diff --git a/esphome/writer.py b/esphome/writer.py index 435c4804f1..44ed1e9179 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -609,6 +609,17 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): if idf_path.is_dir(): _LOGGER.info("Deleting %s", idf_path) rmtree(idf_path) + # The PlatformIO pch artifacts live at the project root so the + # relative -include resolves; a partial clean must drop them too + for name in ( + "esphome_pch.h", + "esphome_pch.h.gch", + "esphome_pch.h.gch.sum", + "esphome_pch.h.gch.failed", + ): + pch_path = CORE.relative_build_path(name) + if pch_path.is_file(): + pch_path.unlink() # The idedata caches are derived from the build but live under the data # dir, not the build path, so they must be removed separately in both diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index de6abba6dc..aeec4e916c 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1766,7 +1766,7 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None: CORE.build_path = tmp_path / name _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") content = _write_ninja(paths, ccache="/usr/bin/ccache") - assert "srccxxflags = -include esphome_pch.h" in content + assert "srccxxflags = -Winvalid-pch -include esphome_pch.h" in content sums.append( (CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text() ) diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index 8edb6be397..7007d278c1 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -97,7 +97,7 @@ def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None assert (proj / "esphome_pch.h.gch").is_file() assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 # Relative include: an absolute path would poison ccache keys - assert scons_env.prepended == ["-include", "esphome_pch.h"] + assert scons_env.prepended == ["-Winvalid-pch", "-include", "esphome_pch.h"] # ccache settings land on the SCons ENV only, never os.environ assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true" From 1b325a66ce8d72176d14701d46a844aecbbd57ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 15:37:15 -0500 Subject: [PATCH 3/3] Warn when a consumer ignores the precompiled header --- esphome/build_gen/espidf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b8f5e95db5..f461e0353d 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -337,6 +337,7 @@ def _pch_cmake() -> str: # a .gch drop out of the TU depfiles, and prepare_pch() touches the # header whenever it rebuilds the .gch so consumers recompile. target_compile_options(${{COMPONENT_LIB}} PRIVATE + "$<$:-Winvalid-pch>" "$<$:-include>" "$<$:{PCH_HEADER_NAME}>" )