mirror of
https://github.com/esphome/esphome.git
synced 2026-10-11 05:44:15 +00:00
301 lines
12 KiB
Plaintext
301 lines
12 KiB
Plaintext
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import posixpath
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
# pylint: disable=E0602
|
|
Import("env") # noqa: F821
|
|
try:
|
|
Import("projenv") # noqa: F821
|
|
except Exception: # noqa: BLE001 -- not exported under -t nobuild
|
|
projenv = None
|
|
|
|
# Precompile the force-includes plus the core headers and force-include
|
|
# the result into C++ src compiles only. Any failure stops the build. This
|
|
# script cannot import esphome: a test pins the values below to
|
|
# build_helpers/pch.py.
|
|
_HEADER_NAME = "esphome_pch.h"
|
|
_SOURCE_NAME = "esphome_pch_src.h"
|
|
_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",)
|
|
_CONSUMER_FLAGS = ["-Winvalid-pch", "-Werror=invalid-pch", "-include", _HEADER_NAME]
|
|
_GUARD_TEXT = f"""\
|
|
#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__)
|
|
#error "The precompiled header was not loaded"
|
|
#else
|
|
#include "{_SOURCE_NAME}"
|
|
#endif
|
|
"""
|
|
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
|
# Before GCC 12 a .gch only loads at the address it was saved from. Checked
|
|
# for this PlatformIO platform, whose compiler is built for a random address
|
|
_OLD_GCC_PIO_PLATFORM = "libretiny"
|
|
_MOVABLE_PCH_GCC = 12
|
|
# Relative: compiles run in the project root
|
|
_CC1_DIR = "pch_cc1"
|
|
_CC1PLUS_WRAPPER = """\
|
|
#!/usr/bin/env -S "{python}" -IS
|
|
# Written by ESPHome: starts cc1plus without address randomisation, so it
|
|
# loads the precompiled header. See esphome/platformio/pch.py.script.
|
|
import ctypes
|
|
import sys
|
|
|
|
real = {real!r}
|
|
libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
|
|
attr = ctypes.create_string_buffer(64)
|
|
libc.posix_spawnattr_init(attr)
|
|
# POSIX_SPAWN_SETEXEC | _POSIX_SPAWN_DISABLE_ASLR
|
|
libc.posix_spawnattr_setflags(attr, ctypes.c_short(0x0040 | 0x0100))
|
|
# What os.fsencode does, without the import
|
|
encoding = sys.getfilesystemencoding()
|
|
argv = [real.encode(), *(a.encode(encoding, "surrogateescape") for a in sys.argv[1:]), None]
|
|
libc._NSGetEnviron.restype = ctypes.POINTER(ctypes.POINTER(ctypes.c_char_p))
|
|
# Replaces this process on success; the return value is the error number
|
|
rc = libc.posix_spawn(None, argv[0], None, attr, (ctypes.c_char_p * len(argv))(*argv),
|
|
libc._NSGetEnviron().contents)
|
|
libc.strerror.restype = ctypes.c_char_p
|
|
sys.exit("ESPHome: cannot start " + real + ": " + libc.strerror(rc).decode())
|
|
"""
|
|
# GCC bug 14940: before these releases the Windows loader maps a .gch only
|
|
# at its saved address. First fixed release per major, 16 on always fixed
|
|
_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)}
|
|
_WINDOWS_GCC_FIXED_DEFAULT = (16, 0)
|
|
_CXX_SUFFIXES = (".cpp", ".cc", ".cxx")
|
|
_HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".inc")
|
|
_INCLUDE_DIR_OPTIONS = ("-I", "-isystem", "-iquote", "-idirafter")
|
|
# Build output and library trees; package versions stand in for libraries
|
|
_SKIPPED_DIRS = (".pioenvs", ".piolibdeps", "managed_components")
|
|
|
|
|
|
def _include_closure(src_dir: Path, roots: list) -> dict:
|
|
"""Include closure: src-relative name -> contents."""
|
|
seen = {}
|
|
stack = [(name, "") for name in roots]
|
|
while stack:
|
|
name, from_dir = stack.pop()
|
|
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
|
rel = posixpath.normpath(candidate)
|
|
if not rel.startswith("..") and (src_dir / rel).is_file():
|
|
break
|
|
else:
|
|
continue
|
|
if rel in seen:
|
|
continue
|
|
data = seen[rel] = (src_dir / rel).read_bytes()
|
|
parent = posixpath.dirname(rel)
|
|
stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data))
|
|
return seen
|
|
|
|
|
|
def _shell_arg(element) -> str:
|
|
"""One compiler argument from one SCons element, as the real spawn
|
|
passes it (shlex would eat path backslashes on Windows)."""
|
|
arg = str(element)
|
|
if " " in arg or os.name == "nt":
|
|
return arg.replace('\\"', '"')
|
|
return (shlex.split(arg) or [""])[0]
|
|
|
|
|
|
def _src_envs(src_dir: Path) -> list:
|
|
"""The environments the C++ src objects compile from: projenv, or the
|
|
ESP-IDF builder's own."""
|
|
found = {}
|
|
src_dir = src_dir.resolve()
|
|
nodes = env.Flatten(env.get("PIOBUILDFILES", [])) # noqa: F821
|
|
for node in nodes:
|
|
source = Path(str(node.sources[0])).resolve()
|
|
if source.suffix in _CXX_SUFFIXES and source.is_relative_to(src_dir):
|
|
found[id(node.env)] = node.env
|
|
if nodes and not found:
|
|
# Flags on projenv would reach no compile
|
|
raise RuntimeError("ESPHome: no C++ source takes the precompiled header")
|
|
return list(found.values()) or [projenv] # noqa: F821
|
|
|
|
|
|
def _driver_output(cxx: str, option: str) -> str:
|
|
"""What the driver prints for ``option``, or "" when it cannot run."""
|
|
try:
|
|
result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510
|
|
except OSError:
|
|
return ""
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str):
|
|
"""Extra driver flags for an old GCC, or None where its .gch cannot be
|
|
used. Linux loads it as is. arm64 macOS loads every program at a random
|
|
address, so there the driver gets a -B directory whose cc1plus starts
|
|
the real one without that randomisation."""
|
|
if pio_platform != _OLD_GCC_PIO_PLATFORM or sys.platform == "linux":
|
|
return []
|
|
major = _driver_output(cxx, "-dumpversion").partition(".")[0]
|
|
if major.isdigit() and int(major) >= _MOVABLE_PCH_GCC:
|
|
return []
|
|
if sys.platform != "darwin" or platform.machine() != "arm64":
|
|
return None
|
|
# The driver prints the bare name when it has no cc1plus of its own
|
|
real = _driver_output(cxx, "-print-prog-name=cc1plus")
|
|
if not os.path.isabs(real):
|
|
return None
|
|
wrapper = proj_dir / _CC1_DIR / "cc1plus"
|
|
wrapper.parent.mkdir(exist_ok=True)
|
|
real = os.path.normpath(real)
|
|
wrapper.write_text(_CC1PLUS_WRAPPER.format(python=sys.executable, real=real))
|
|
wrapper.chmod(0o755)
|
|
return [f"-B{_CC1_DIR}/"]
|
|
|
|
|
|
def _gcc_loads_pch_here(cxx: str) -> bool:
|
|
"""Whether a .gch loads here; ESPHOME_PCH_ENABLE=1, as esphome
|
|
normalizes it, insists."""
|
|
if sys.platform != "win32" or os.environ.get("ESPHOME_PCH_ENABLE") == "1":
|
|
return True
|
|
# GCC ends the first --version line with its version; clang names itself
|
|
banner = _driver_output(cxx, "--version").partition("\n")[0]
|
|
if "clang" in banner.lower():
|
|
return True
|
|
found = re.findall(r"\d+(?:\.\d+)+", banner)
|
|
version = tuple(int(p) for p in found[-1].split(".")) if found else ()
|
|
if version and version[:2] >= _WINDOWS_GCC_FIXED.get(version[0], _WINDOWS_GCC_FIXED_DEFAULT):
|
|
return True
|
|
print(
|
|
f"ESPHome: GCC {'.'.join(map(str, version)) or 'of unknown version'} cannot load a "
|
|
"precompiled header on Windows (GCC bug 14940); compiling without it"
|
|
)
|
|
return False
|
|
|
|
|
|
def _setup_pch() -> None:
|
|
if projenv is None:
|
|
if "nobuild" in COMMAND_LINE_TARGETS: # noqa: F821
|
|
return
|
|
raise RuntimeError("ESPHome: no project environment for the pch")
|
|
# SCons compiles run in the project root, so the relative -include
|
|
# resolves; an absolute path would enter the ccache key
|
|
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
|
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
|
|
header = proj_dir / _HEADER_NAME
|
|
source = proj_dir / _SOURCE_NAME
|
|
gch = Path(f"{header}.gch")
|
|
sum_path = Path(f"{gch}.sum")
|
|
src_envs = _src_envs(src_dir)
|
|
|
|
cxx = src_envs[0].subst("$CXX")
|
|
if not _gcc_loads_pch_here(cxx):
|
|
return
|
|
pio_platform = env.PioPlatform() # noqa: F821
|
|
extra = _old_gcc_flags(proj_dir, pio_platform.name, cxx)
|
|
if extra is None:
|
|
print("ESPHome: this compiler cannot load a precompiled header here; compiling without it")
|
|
return
|
|
# The header holds the -include entries itself, so the .gch compile must
|
|
# not see them; consumers keep theirs, which the .gch then satisfies
|
|
flags = []
|
|
include_headers = []
|
|
flag_it = iter(
|
|
_shell_arg(element)
|
|
for element in src_envs[0].subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0]
|
|
)
|
|
for tok in flag_it:
|
|
if tok == "-include":
|
|
include_headers.append(next(flag_it, ""))
|
|
elif tok.startswith("-include") and not tok.startswith("-include-"):
|
|
include_headers.append(tok[len("-include") :])
|
|
else:
|
|
flags.append(tok)
|
|
# Folded first and in order, as the compiler would have read them. An
|
|
# absolute path would enter the checksum, so it cannot be folded
|
|
if absolute := [name for name in include_headers if Path(name).is_absolute()]:
|
|
print(
|
|
f"ESPHome: the force-include of {absolute} prevents the precompiled "
|
|
"header; compiling without it"
|
|
)
|
|
return
|
|
headers = [*include_headers, *_DEFAULT_HEADERS]
|
|
content = "".join(f'#include "{name}"\n' for name in headers)
|
|
|
|
digest = hashlib.sha256()
|
|
digest.update(content.encode())
|
|
digest.update(cxx.encode())
|
|
# As ccache does: strip the build path so devices share cache entries
|
|
flags_id = " ".join(flags)
|
|
if basedir := os.environ.get("CCACHE_BASEDIR"):
|
|
flags_id = flags_id.replace(basedir, "")
|
|
digest.update(flags_id.encode())
|
|
# GCC does not check a .gch against its headers and PlatformIO package
|
|
# paths carry no version
|
|
for package in sorted(pio_platform.packages):
|
|
digest.update(f"{package}={pio_platform.get_package_version(package)}\0".encode())
|
|
# Libraries carry their version in lib_deps
|
|
digest.update(repr(env.GetProjectOption("lib_deps", [])).encode()) # noqa: F821
|
|
closure = _include_closure(src_dir, headers)
|
|
for rel in sorted(closure):
|
|
digest.update(rel.encode())
|
|
digest.update(closure[rel])
|
|
digest.update(b"\0")
|
|
# The ESP-IDF configuration: sdkconfig.h sits in the build output
|
|
for config in sorted(proj_dir.glob("sdkconfig.*")):
|
|
digest.update(config.read_bytes())
|
|
# Project-local include dirs (rp2's lwip_override) hold generated
|
|
# headers outside src/
|
|
prev = ""
|
|
for tok in flags:
|
|
inc = ""
|
|
for opt in _INCLUDE_DIR_OPTIONS:
|
|
if prev == opt:
|
|
inc = tok
|
|
elif tok.startswith(opt) and len(tok) > len(opt):
|
|
inc = tok[len(opt) :]
|
|
prev = tok
|
|
# Relative dirs are relative to the project, where compiles run
|
|
inc_dir = Path(os.path.normpath(proj_dir / inc))
|
|
if (
|
|
not inc
|
|
or inc_dir == proj_dir
|
|
or not inc_dir.is_relative_to(proj_dir)
|
|
or inc_dir.is_relative_to(src_dir)
|
|
or any(inc_dir.is_relative_to(proj_dir / name) for name in _SKIPPED_DIRS)
|
|
):
|
|
continue
|
|
for local in sorted(inc_dir.rglob("*")):
|
|
if local.suffix in _HEADER_SUFFIXES and local.is_file():
|
|
digest.update(str(local.relative_to(proj_dir)).encode())
|
|
digest.update(local.read_bytes())
|
|
digest.update(b"\0")
|
|
checksum = digest.hexdigest()
|
|
|
|
# The ccache .sum file doubles as the freshness stamp
|
|
if not (
|
|
gch.is_file()
|
|
and sum_path.is_file()
|
|
and sum_path.read_text(encoding="utf-8").strip() == checksum
|
|
):
|
|
header.write_text(_GUARD_TEXT, encoding="utf-8")
|
|
source.write_text(content, encoding="utf-8")
|
|
result = subprocess.run( # noqa: PLW1510
|
|
[cxx, "-x", "c++-header", *flags, *extra, "-c", str(source), "-o", str(gch)],
|
|
cwd=proj_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"ESPHome: could not compile the precompiled header:\n{result.stderr}"
|
|
)
|
|
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
|
|
|
# Prepended: GCC only loads a .gch for the first -include
|
|
for src_env in src_envs:
|
|
src_env.Prepend(CXXFLAGS=[*extra, *_CONSUMER_FLAGS])
|
|
print(
|
|
"ESPHome: Compiling with a precompiled header "
|
|
"(set ESPHOME_PCH_ENABLE=0 to disable)"
|
|
)
|
|
|
|
|
|
_setup_pch()
|