[core] Add add_cmake_arg (#18498)

This commit is contained in:
David van 't Wout
2026-08-20 12:30:33 -05:00
committed by GitHub
parent a3ea77c2f1
commit 2ab09e1a77
9 changed files with 187 additions and 25 deletions
+23 -15
View File
@@ -72,6 +72,13 @@ def has_discovered_components() -> bool:
return get_available_components() is not None return get_available_components() is not None
def _cmake_quote(value: str) -> str:
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
whitespace, quotes, and '$', so only backslashes need escaping."""
escaped = value.replace("\\", "\\\\")
return f'"{escaped}"'
def get_project_cmakelists(minimal: bool = False) -> str: def get_project_cmakelists(minimal: bool = False) -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project. """Generate the top-level CMakeLists.txt for ESP-IDF project.
@@ -114,6 +121,15 @@ def get_project_cmakelists(minimal: bool = False) -> str:
else "" else ""
) )
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
# (discovery) writes too so excluded components never register.
cmake_args = "\n".join(
f"set({name} {_cmake_quote(value)})"
for name, value in sorted(CORE.cmake_args.items())
)
# Per-project list exposed as a CMake variable so converted PIO libs # Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists. # project-specific names into their cached CMakeLists.
@@ -129,18 +145,6 @@ def get_project_cmakelists(minimal: bool = False) -> str:
for name in get_managed_component_require_names() for name in get_managed_component_require_names()
) )
# Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS
# minus per-component re-includes). project.cmake reads the plain
# EXCLUDE_COMPONENTS variable when seeding the component list, so this
# must be set before project(). Emitted on minimal writes too so the
# discovery reconfigure never registers the excluded components.
excluded_components = get_excluded_builtin_components()
exclude_components_var = (
f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")'
if excluded_components
else ""
)
# Built-in IDF components exposed via our own property (not IDF's # Built-in IDF components exposed via our own property (not IDF's
# __COMPONENT_REQUIRES_COMMON, which would append them to every # __COMPONENT_REQUIRES_COMMON, which would append them to every
# component's REQUIRES including real IDF components). Referenced by # component's REQUIRES including real IDF components). Referenced by
@@ -150,13 +154,17 @@ def get_project_cmakelists(minimal: bool = False) -> str:
# project_description.json from a build without exclusions may still # project_description.json from a build without exclusions may still
# list them, and requiring an excluded component pulls it back into # list them, and requiring an excluded component pulls it back into
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
# two can never disagree within one generated file.
builtin_components_property = ( builtin_components_property = (
"" ""
if minimal if minimal
else "\n".join( else "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
for name in sorted( for name in sorted(
set(get_available_components() or []).difference(excluded_components) set(get_available_components() or []).difference(
CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";")
)
) )
) )
) )
@@ -184,9 +192,9 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
set(IDF_TARGET {idf_target}) set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cmake_args}
{exclude_components_var} include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{cpp_standard_options} {cpp_standard_options}
+11
View File
@@ -63,6 +63,17 @@ def get_ini_content():
# Add extra script for C++ flags # Add extra script for C++ flags
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
# Add CMake args. A user-supplied value (str or list) is deliberately
# replaced; this option was always overwritten at FINAL priority.
if CORE.cmake_args:
CORE.add_platformio_option(
"board_build.cmake_extra_args",
" ".join(
f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items())
),
replace=True,
)
content = "[platformio]\n" content = "[platformio]\n"
content += f"description = ESPHome {__version__}\n" content += f"description = ESPHome {__version__}\n"
+1
View File
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
add, add,
add_build_flag, add_build_flag,
add_build_unflag, add_build_unflag,
add_cmake_arg,
add_cxx_build_flag, add_cxx_build_flag,
add_define, add_define,
add_global, add_global,
+11 -8
View File
@@ -760,9 +760,10 @@ def include_builtin_idf_component(name: str) -> None:
def get_excluded_builtin_components() -> list[str]: def get_excluded_builtin_components() -> list[str]:
"""Return the sorted built-in IDF components excluded from the build. """Return the sorted built-in IDF components excluded from the build.
Single accessor for both build writers: the PlatformIO path passes it as The set reaches both build writers as the ``EXCLUDE_COMPONENTS`` CMake
``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the arg (registered via ``cg.add_cmake_arg`` at FINAL priority); the native
generated CMakeLists. ESP-IDF writer also reads it directly to filter the built-in component
list.
""" """
return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ()))
@@ -2148,14 +2149,16 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
def register_exclude_components_cmake_arg() -> None:
"""Register the current exclusion set as the EXCLUDE_COMPONENTS cmake arg."""
if excluded := get_excluded_builtin_components():
cg.add_cmake_arg("EXCLUDE_COMPONENTS", ";".join(excluded))
@coroutine_with_priority(CoroPriority.FINAL) @coroutine_with_priority(CoroPriority.FINAL)
async def _write_exclude_components() -> None: async def _write_exclude_components() -> None:
"""Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions."""
if excluded := get_excluded_builtin_components(): register_exclude_components_cmake_arg()
cg.add_platformio_option(
"board_build.cmake_extra_args",
f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}",
)
@coroutine_with_priority(CoroPriority.FINAL) @coroutine_with_priority(CoroPriority.FINAL)
+33 -2
View File
@@ -641,6 +641,8 @@ class EsphomeCore:
self.platformio_libraries: dict[str, Library] = {} self.platformio_libraries: dict[str, Library] = {}
# A set of build flags to set in the platformio project # A set of build flags to set in the platformio project
self.build_flags: set[str] = set() self.build_flags: set[str] = set()
# A map of CMake args to apply to build systems that use CMake.
self.cmake_args: dict[str, str] = {}
# A set of build flags that apply to C++ compiles only (CXXFLAGS / # A set of build flags that apply to C++ compiles only (CXXFLAGS /
# CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C
self.cxx_build_flags: set[str] = set() self.cxx_build_flags: set[str] = set()
@@ -704,6 +706,7 @@ class EsphomeCore:
self.global_statements = [] self.global_statements = []
self.platformio_libraries = {} self.platformio_libraries = {}
self.build_flags = set() self.build_flags = set()
self.cmake_args = {}
self.cxx_build_flags = set() self.cxx_build_flags = set()
self.build_unflags = set() self.build_unflags = set()
self.cpp_standard = None self.cpp_standard = None
@@ -1062,6 +1065,30 @@ class EsphomeCore:
_LOGGER.debug("Adding build flag: %s", build_flag) _LOGGER.debug("Adding build flag: %s", build_flag)
return build_flag return build_flag
def add_cmake_arg(self, name: str, value: str) -> None:
"""Register a CMake variable for CMake-based toolchains.
The value must not contain whitespace or quotes (the PlatformIO
backend passes all args to CMake as a single space-joined string
of ``-DNAME=VALUE`` pairs) or ``$`` (expanded by CMake on the
ESP-IDF path but interpolated differently or passed through by
PlatformIO).
"""
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise ValueError(f"Invalid CMake arg name: {name!r}")
if re.search(r"[\s\"'$]", value):
raise ValueError(
f"CMake arg {name} value {value!r} must not contain "
"whitespace, quotes, or '$'"
)
old = self.cmake_args.get(name)
if old is not None and old != value:
_LOGGER.warning(
"CMake arg %s already set to %s; overwriting with %s", name, old, value
)
self.cmake_args[name] = value
_LOGGER.debug("Adding CMake arg: %s=%s", name, value)
def add_cxx_build_flag(self, build_flag: str) -> str: def add_cxx_build_flag(self, build_flag: str) -> str:
self.cxx_build_flags.add(build_flag) self.cxx_build_flags.add(build_flag)
_LOGGER.debug("Adding C++ build flag: %s", build_flag) _LOGGER.debug("Adding C++ build flag: %s", build_flag)
@@ -1091,10 +1118,14 @@ class EsphomeCore:
_LOGGER.debug("Adding define: %s", define) _LOGGER.debug("Adding define: %s", define)
return define return define
def add_platformio_option(self, key: str, value: str | list[str]) -> None: def add_platformio_option(
self, key: str, value: str | list[str], *, replace: bool = False
) -> None:
"""Set a platformio.ini option; list values append to an existing list
unless ``replace`` is True, which overwrites any existing value."""
new_val = value new_val = value
old_val = self.platformio_options.get(key) old_val = self.platformio_options.get(key)
if isinstance(old_val, list): if not replace and isinstance(old_val, list):
assert isinstance(value, list) assert isinstance(value, list)
new_val = old_val + value new_val = old_val + value
self.platformio_options[key] = new_val self.platformio_options[key] = new_val
+5
View File
@@ -699,6 +699,11 @@ def add_build_flag(build_flag: str):
CORE.add_build_flag(build_flag) CORE.add_build_flag(build_flag)
def add_cmake_arg(name: str, value: str) -> None:
"""Add a CMake arg for CMake-based toolchains; see ``EsphomeCore.add_cmake_arg``."""
CORE.add_cmake_arg(name, value)
def add_cxx_build_flag(build_flag: str) -> None: def add_cxx_build_flag(build_flag: str) -> None:
"""Add a global build flag that applies to C++ compiles only. """Add a global build flag that applies to C++ compiles only.
+27
View File
@@ -16,6 +16,7 @@ from esphome.components.esp32 import (
KEY_PATH, KEY_PATH,
KEY_REF, KEY_REF,
KEY_REPO, KEY_REPO,
register_exclude_components_cmake_arg,
) )
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import KEY_CORE from esphome.const import KEY_CORE
@@ -137,6 +138,27 @@ def test_get_project_cmakelists_full_emits_builtin_components_property(
assert "JPEGDEC APPEND" not in content assert "JPEGDEC APPEND" not in content
def test_get_project_cmakelists_emits_cmake_args() -> None:
"""Args registered via CORE.add_cmake_arg() are emitted as set() lines,
on minimal writes too."""
CORE.add_cmake_arg("EXECUTABLE_COMPONENT_NAME", "src")
content = _render(minimal=True)
assert 'set(EXECUTABLE_COMPONENT_NAME "src")' in content
def test_get_project_cmakelists_escapes_backslashes_in_cmake_args() -> None:
"""Backslashes (the only character escaping applies to; the rest are
rejected at registration) are doubled so CMake reads the value back
verbatim."""
CORE.add_cmake_arg("MY_PATH", r"C:\esp\idf")
content = _render(minimal=True)
assert r'set(MY_PATH "C:\\esp\\idf")' in content
def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None:
"""Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are
dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale
@@ -151,6 +173,7 @@ def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None
}, },
) )
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"}
register_exclude_components_cmake_arg()
content = _render() content = _render()
@@ -169,6 +192,7 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None:
"""The discovery (minimal) write also excludes components so they never """The discovery (minimal) write also excludes components so they never
register in project_description.json.""" register in project_description.json."""
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"}
register_exclude_components_cmake_arg()
content = _render(minimal=True) content = _render(minimal=True)
@@ -177,6 +201,8 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None:
def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None:
"""No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" """No EXCLUDE_COMPONENTS line at all when nothing is excluded."""
register_exclude_components_cmake_arg()
content = _render() content = _render()
assert "EXCLUDE_COMPONENTS" not in content assert "EXCLUDE_COMPONENTS" not in content
@@ -197,6 +223,7 @@ def test_include_builtin_idf_component_removes_exclusion() -> None:
assert get_excluded_builtin_components() == ["unity"] assert get_excluded_builtin_components() == ["unity"]
register_exclude_components_cmake_arg()
content = _render() content = _render()
assert 'set(EXCLUDE_COMPONENTS "unity")' in content assert 'set(EXCLUDE_COMPONENTS "unity")' in content
@@ -169,6 +169,7 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(CORE, "platformio_libraries", {}) monkeypatch.setattr(CORE, "platformio_libraries", {})
monkeypatch.setattr(CORE, "build_flags", set()) monkeypatch.setattr(CORE, "build_flags", set())
monkeypatch.setattr(CORE, "build_unflags", set()) monkeypatch.setattr(CORE, "build_unflags", set())
monkeypatch.setattr(CORE, "cmake_args", {})
def test_get_ini_content_pins_cpp_standard( def test_get_ini_content_pins_cpp_standard(
@@ -202,6 +203,49 @@ def test_get_ini_content_no_cpp_standard(
assert "-std=" not in content assert "-std=" not in content
def test_get_ini_content_emits_cmake_args(
clean_core: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Registered args are space-joined into one option, sorted by name."""
monkeypatch.setattr(
CORE,
"cmake_args",
{"EXECUTABLE_COMPONENT_NAME": "src", "EXCLUDE_COMPONENTS": "unity"},
)
content = platformio.get_ini_content()
assert (
"board_build.cmake_extra_args = "
"-DEXCLUDE_COMPONENTS=unity -DEXECUTABLE_COMPONENT_NAME=src" in content
)
def test_get_ini_content_no_cmake_option_when_no_args(clean_core: None) -> None:
"""No board_build.cmake_extra_args line at all when nothing registered
(ESP8266/RP2040/LibreTiny builds must not get a blank option)."""
content = platformio.get_ini_content()
assert "board_build.cmake_extra_args" not in content
def test_get_ini_content_overwrites_list_valued_user_cmake_option(
clean_core: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A user-supplied board_build.cmake_extra_args may be a list; the
registered args must replace it without tripping add_platformio_option's
list-append assert."""
monkeypatch.setattr(
CORE, "platformio_options", {"board_build.cmake_extra_args": ["-DFOO=1"]}
)
monkeypatch.setattr(CORE, "cmake_args", {"EXECUTABLE_COMPONENT_NAME": "src"})
content = platformio.get_ini_content()
assert "board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src" in content
assert "-DFOO=1" not in content
def test_write_cxx_flags_script_emits_registered_flags( def test_write_cxx_flags_script_emits_registered_flags(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
+32
View File
@@ -990,3 +990,35 @@ class TestEsphomeCore:
) )
# The unflag is still recorded either way. # The unflag is still recorded either way.
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
def test_add_cmake_arg(self, target) -> None:
target.add_cmake_arg("EXCLUDE_COMPONENTS", "unity;esp_lcd")
assert target.cmake_args == {"EXCLUDE_COMPONENTS": "unity;esp_lcd"}
@pytest.mark.parametrize("name", ["", "BAD NAME", 'A"B', "A(B)", "1ABC"])
def test_add_cmake_arg__rejects_invalid_name(self, target, name: str) -> None:
with pytest.raises(ValueError, match="Invalid CMake arg name"):
target.add_cmake_arg(name, "value")
@pytest.mark.parametrize("value", ["a b", "a\tb", 'a"b', "a'b", "a${FOO}b"])
def test_add_cmake_arg__rejects_invalid_value(self, target, value: str) -> None:
"""Whitespace and quotes are rejected (the PlatformIO backend passes
args as one space-joined string, which would split such a value), and
so is '$' (expanded differently by CMake and PlatformIO)."""
with pytest.raises(ValueError, match="must not contain"):
target.add_cmake_arg("MY_ARG", value)
def test_add_cmake_arg__warns_on_overwrite(
self, target, caplog: pytest.LogCaptureFixture
) -> None:
"""Re-registering with a different value is last-writer-wins; warn so
the silently dropped value is diagnosable."""
target.add_cmake_arg("MY_ARG", "one")
target.add_cmake_arg("MY_ARG", "one")
assert "overwriting" not in caplog.text
target.add_cmake_arg("MY_ARG", "two")
assert (
"CMake arg MY_ARG already set to one; overwriting with two" in caplog.text
)
assert target.cmake_args == {"MY_ARG": "two"}