diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 4eeaa30e7f..4655d1c54d 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -20,6 +20,7 @@ from esphome.platformio.library import ( DEFAULT_BUILD_SRC_FILTER, ESPHOME_DATA_EXTRA_CMAKE_KEY, ESPHOME_DATA_KEY, + ESPHOME_DATA_LINK_FLAGS_KEY, SRC_FILE_EXTENSIONS, ConvertedLibrary as IDFComponent, LibraryBackend, @@ -205,6 +206,16 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: content += f" {str_build_flag}\n" content += ")\n" + # Extra-script LINKFLAGS: routed to the link line; in + # target_compile_options they would be silently ineffective + if link_flags := component.data.get(ESPHOME_DATA_KEY, {}).get( + ESPHOME_DATA_LINK_FLAGS_KEY, [] + ): + content += "target_link_options(${COMPONENT_LIB} INTERFACE\n" + for link_flag in link_flags: + content += f" {escape_entry(link_flag)}\n" + content += ")\n" + # Add custom CMake scripts content += "\n".join( component.data.get(ESPHOME_DATA_KEY, {}).get(ESPHOME_DATA_EXTRA_CMAKE_KEY, []) diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index 9fa73bafac..8ffd6ee24b 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -17,6 +17,7 @@ import shlex from typing import TYPE_CHECKING, Any from esphome.core import EsphomeError +from esphome.platformio.library import ESPHOME_DATA_KEY, ESPHOME_DATA_LINK_FLAGS_KEY if TYPE_CHECKING: from esphome.platformio.library import ConvertedLibrary @@ -63,6 +64,11 @@ def apply_extra_script( board_mcu=board_mcu(), pio_platform=pio_platform, ) + if link_flags := _str_entries(result.linkflags, "LINKFLAGS"): + # Kept apart from build.flags: the CMake emitters route those to + # target_compile_options, where a link flag is silently ineffective + esphome_data = component.data.setdefault(ESPHOME_DATA_KEY, {}) + esphome_data.setdefault(ESPHOME_DATA_LINK_FLAGS_KEY, []).extend(link_flags) extra_flags = captured_as_build_flags(result, library_dir=source_path) if not extra_flags: return @@ -148,6 +154,12 @@ class _FakeSConsEnv: return self._vars.get(key, "") def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name) + self._add(kwargs, prepend=False) + + def Prepend(self, **kwargs) -> None: # noqa: N802 (SCons API name) + self._add(kwargs, prepend=True) + + def _add(self, kwargs: dict[str, Any], *, prepend: bool) -> None: for key, value in kwargs.items(): if key not in _CAPTURED_KEYS: # Warn once per key so a loop of Appends cannot spam @@ -163,13 +175,16 @@ class _FakeSConsEnv: else: items = list(value) if isinstance(value, (list, tuple)) else [value] bucket = getattr(self.result, key.lower()) - bucket.extend(items) + if prepend: + # SCons order: new values ahead of what is already there + # (scripts prepend LIBS for static-link symbol resolution) + bucket[:0] = items + else: + bucket.extend(items) - # Same keys, same flattened capture; ordering/dedup don't matter since - # the consumer re-orders anyway - Prepend = Append + # Dedup is not modelled; a repeated flag is harmless on the command line AppendUnique = Append - PrependUnique = Append + PrependUnique = Prepend # ----- Everything else is a no-op so unsupported scripts don't crash ----- @@ -263,22 +278,22 @@ def run_extra_script( return env.result +def _str_entries(bucket: list, kind: str) -> list[str]: + # Third-party scripts legally append SCons nodes, ints, or dicts; + # stringifying those into flags would hand the compiler garbage + good = [entry for entry in bucket if isinstance(entry, str)] + for entry in bucket: + if not isinstance(entry, str): + _LOGGER.warning("Ignoring unsupported %s entry %r", kind, entry) + return good + + def captured_as_build_flags( result: ExtraScriptResult, *, library_dir: Path ) -> list[str]: """Translate captured env vars into -L/-l/-D/raw build flags; path entries anchor to ``library_dir`` so the build files stay portable.""" flags: list[str] = [] - - def _strs(bucket: list, kind: str) -> list[str]: - # Third-party scripts legally append SCons nodes, ints, or dicts; - # stringifying those into flags would hand the compiler garbage - good = [entry for entry in bucket if isinstance(entry, str)] - for entry in bucket: - if not isinstance(entry, str): - _LOGGER.warning("Ignoring unsupported %s entry %r", kind, entry) - return good - library_root = library_dir.resolve() def _anchored(path: str) -> str: @@ -292,12 +307,14 @@ def captured_as_build_flags( # shlex.quote so a spaced path survives lex_build_flags as one token flags.extend( - f"-I{shlex.quote(_anchored(path))}" for path in _strs(result.cpppath, "CPPPATH") + f"-I{shlex.quote(_anchored(path))}" + for path in _str_entries(result.cpppath, "CPPPATH") ) flags.extend( - f"-L{shlex.quote(_anchored(path))}" for path in _strs(result.libpath, "LIBPATH") + f"-L{shlex.quote(_anchored(path))}" + for path in _str_entries(result.libpath, "LIBPATH") ) - flags.extend(f"-l{shlex.quote(lib)}" for lib in _strs(result.libs, "LIBS")) + flags.extend(f"-l{shlex.quote(lib)}" for lib in _str_entries(result.libs, "LIBS")) for define in result.cppdefines: # SCons also accepts nested containers; formatting those blind # would hand the compiler garbage like -D{'FOO': '1'} @@ -317,7 +334,8 @@ def captured_as_build_flags( else: _LOGGER.warning("Ignoring unsupported CPPDEFINES entry %r", define) # Each captured entry is one argv token in SCons; quote so the - # lex_build_flags round-trip cannot split a spaced value into two - flags.extend(shlex.quote(f) for f in _strs(result.linkflags, "LINKFLAGS")) - flags.extend(shlex.quote(f) for f in _strs(result.cppflags, "CPPFLAGS")) + # lex_build_flags round-trip cannot split a spaced value into two. + # LINKFLAGS are deliberately absent: they travel via + # ESPHOME_DATA_LINK_FLAGS_KEY straight to the link line. + flags.extend(shlex.quote(f) for f in _str_entries(result.cppflags, "CPPFLAGS")) return flags diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 7f08c33d3b..8f181a757f 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -88,6 +88,9 @@ _EXTRACTED_MARKER = ".esphome_extracted" ESPHOME_DATA_KEY = "ESPHOME" ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" +# Captured extra-script LINKFLAGS; kept apart from build.flags so they reach +# the link line (target_link_options), not target_compile_options +ESPHOME_DATA_LINK_FLAGS_KEY = "LINK_FLAGS" class Source: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index a504cae8dd..957c98f8ad 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -23,6 +23,8 @@ from esphome.espidf.component import ( ) import esphome.platformio.library from esphome.platformio.library import ( + ESPHOME_DATA_KEY, + ESPHOME_DATA_LINK_FLAGS_KEY, ConvertedLibrary as IDFComponent, GitSource, URLSource, @@ -292,6 +294,25 @@ def test_generate_cmakelists_txt_multi_token_flag(tmp_component): assert ' "-include"\n "cp_custom_alloc.h"\n' in content +def test_generate_cmakelists_txt_extra_script_link_flags(tmp_component): + """Captured extra-script LINKFLAGS come out as target_link_options, not + compile options where they would be silently ineffective.""" + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + + tmp_component.data = { + ESPHOME_DATA_KEY: {ESPHOME_DATA_LINK_FLAGS_KEY: ["-Wl,--gc-sections"]} + } + + content = generate_cmakelists_txt(tmp_component) + assert ( + 'target_link_options(${COMPONENT_LIB} INTERFACE\n "-Wl,--gc-sections"\n)' + in content + ) + assert "target_compile_options" not in content + + def test_generate_cmakelists_txt_space_separated_classified_flags(tmp_component): # Space-separated -I/-L/-l entries routed to INCLUDE_DIRS and the link # handling before the shlex split was added; splitting must not leak diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index ebe61d0d1f..90ae4aa43b 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -18,6 +18,8 @@ from esphome.platformio.extra_script import ( run_extra_script, ) from esphome.platformio.library import ( + ESPHOME_DATA_KEY, + ESPHOME_DATA_LINK_FLAGS_KEY, ConvertedLibrary as IDFComponent, URLSource, lex_build_flags, @@ -62,7 +64,8 @@ def test_extra_script_captures_libpath_libs_and_defines(tmp_path): assert "-lalgobsec" in tokens assert "-DFOO" in tokens assert "-DBAR=1" in tokens - assert "-Wl,--gc-sections" in tokens + # LINKFLAGS travel via the link-flags channel, never the compile flags + assert "-Wl,--gc-sections" not in tokens def test_extra_script_libpath_relative_resolves_against_library_dir( @@ -194,9 +197,11 @@ def test_captured_nonstring_buckets_warn_and_skip(tmp_path, caplog) -> None: apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") flags = c.data["build"]["flags"] - assert "-lm" in flags and "-Wl,-x" in flags and "-Os" in flags + assert "-lm" in flags and "-Os" in flags + assert c.data[ESPHOME_DATA_KEY][ESPHOME_DATA_LINK_FLAGS_KEY] == ["-Wl,-x"] assert not any("42" in f or "no" in f or "3.5" in f for f in flags) assert "Ignoring unsupported LIBS entry 42" in caplog.text + assert "Ignoring unsupported LINKFLAGS entry {'no': 1}" in caplog.text assert "Ignoring unsupported LIBPATH entry 7" in caplog.text @@ -443,6 +448,18 @@ def test_append_variants_capture_like_append(method: str) -> None: assert env.result.libpath == ["lib"] +@pytest.mark.parametrize("method", ("Prepend", "PrependUnique")) +def test_prepend_inserts_ahead_of_existing(method: str) -> None: + """Prepend keeps SCons order: new values land ahead of what is already + captured (scripts prepend LIBS for static-link symbol resolution).""" + env = _FakeSConsEnv( + board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" + ) + env.Append(LIBS=["m"]) + getattr(env, method)(LIBS=["algobsec", "bsec"]) + assert env.result.libs == ["algobsec", "bsec", "m"] + + def test_env_get_unknown_key_warns_once(caplog) -> None: """A script branching on an unmodelled env var is diagnosable.""" env = _FakeSConsEnv( @@ -455,10 +472,9 @@ def test_env_get_unknown_key_warns_once(caplog) -> None: assert "BOARD_MCU" not in caplog.text -def test_spaced_linkflag_survives_relexing(tmp_path) -> None: +def test_spaced_cppflag_survives_relexing(tmp_path) -> None: """A captured argv token with a space stays one token after lexing.""" result = ExtraScriptResult( - linkflags=["-Wl,-T my linker.ld"], cppflags=["-include my hdr.h"], cppdefines=[("MSG", '"hello world"'), "PLAIN"], ) @@ -466,7 +482,6 @@ def test_spaced_linkflag_survives_relexing(tmp_path) -> None: assert lex_build_flags(flags, "test") == [ '-DMSG="hello world"', "-DPLAIN", - "-Wl,-T my linker.ld", "-include my hdr.h", ]