diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..c43e4cac87 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -43,6 +43,11 @@ DOMAIN = "api" DEPENDENCIES = ["network"] CODEOWNERS = ["@esphome/core"] +# Keep in sync with platformio.ini and esphome/idf_component.yml. +# LIBSODIUM_VERSION must match the version noise-c pins in its idf_component.yml. +NOISE_C_VERSION = "0.1.15" +LIBSODIUM_VERSION = "1.10021.2" + def AUTO_LOAD(config: ConfigType) -> list[str]: """Conditionally auto-load json only when capture_response is used.""" @@ -497,7 +502,23 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + # Both libraries build themselves as ESP-IDF components, so on ESP-IDF + # they are pulled straight from the component registry instead of going + # through ESPHome's PlatformIO-library converter. Not on the Arduino + # framework: arduino-esp32 brings its own espressif/libsodium (on IDF + # < 6.0), and IDF refuses to build with two managed components whose + # names differ only by namespace. + if CORE.is_esp32 and not CORE.using_arduino: + from esphome.components.esp32 import add_idf_component + + add_idf_component(name="esphome/noise-c", ref=NOISE_C_VERSION) + # noise-c pulls libsodium in itself, but declaring it here too keeps + # other components that depend on it (wireguard, via esp_wireguard) + # from converting a second copy of the PlatformIO library alongside + # this managed one, which IDF rejects as a duplicate requirement. + add_idf_component(name="esphome/libsodium", ref=LIBSODIUM_VERSION) + else: + cg.add_library("esphome/noise-c", NOISE_C_VERSION) # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..da10b33e80 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3235,7 +3235,12 @@ def _write_idf_component_yml(): # Don't process arduino libraries if name not in ARDUINO_DISABLED_LIBRARIES ] - for component in generate_idf_components(libraries): + # A library that is also declared as a managed component must not be + # converted as well, or IDF sees the same requirement from two + # components and refuses to build. Converted components still link + # against it via ${ESPHOME_PROJECT_MANAGED_COMPONENTS}. + managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {})) + for component in generate_idf_components(libraries, managed=managed): dependencies[component.get_sanitized_name()] = { "override_path": str(component.path) } diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index c91db775a3..6974432285 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -232,6 +232,17 @@ def _parse_lib_deps(platformio_ini: Path, framework: str): return libs +def _esphome_manifest_deps() -> set[str]: + """Names of the managed components declared in ``esphome/idf_component.yml``.""" + import yaml + + esphome_dir = Path(__file__).resolve().parent.parent + manifest = yaml.safe_load( + (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") + ) + return set(manifest.get("dependencies") or {}) + + def _convert_pio_libs( platformio_ini: Path, framework: str ) -> dict[str, dict[str, str]]: @@ -244,12 +255,20 @@ def _convert_pio_libs( The whole library set is resolved as a single batch so a shared transitive dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard) is deduplicated to one component instead of clashing override_path entries. + + Libraries ESPHome's own manifest already provides as managed components + (noise-c, libsodium, ...) are skipped, mirroring what the real esp32 build + does -- converting them too would make IDF see the same requirement twice. + On Arduino those entries are rule-disabled in the manifest (arduino-esp32 + brings its own libsodium), so nothing provides them there and they have to + go through the converter as before. """ from esphome.espidf.component import generate_idf_components libraries = _parse_lib_deps(platformio_ini, framework) + managed = set() if framework == "arduino" else _esphome_manifest_deps() deps: dict[str, dict[str, str]] = {} - for component in generate_idf_components(libraries): + for component in generate_idf_components(libraries, managed=managed): deps[component.get_sanitized_name()] = {"override_path": str(component.path)} return deps @@ -267,19 +286,13 @@ def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32 resolves to the same component rather than conflicting. """ - import yaml - from esphome.components.esp32 import ( ARDUINO_EXCLUDED_IDF_COMPONENTS, _idf_component_dep_name, _idf_component_stub_name, ) - esphome_dir = Path(__file__).resolve().parent.parent - base_manifest = yaml.safe_load( - (esphome_dir / "idf_component.yml").read_text(encoding="utf-8") - ) - esphome_deps = set(base_manifest.get("dependencies") or {}) + esphome_deps = _esphome_manifest_deps() stubs_dir = work_dir / "component_stubs" stubs_dir.mkdir(parents=True, exist_ok=True) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index aa6f10c261..fde135b81e 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -310,12 +310,22 @@ def _emit_idf_component(component: IDFComponent) -> None: ) -def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: - """Resolve and convert a batch of PlatformIO libraries to IDF components.""" +def generate_idf_components( + libraries: list[Library], managed: set[str] | None = None +) -> list[IDFComponent]: + """Resolve and convert a batch of PlatformIO libraries to IDF components. + + ``managed`` names the registry components already declared in the project + manifest (via ``add_idf_component``). Those are skipped by the converter -- + a library must not be both converted and managed, or IDF fails component + discovery with "Requirement __ and requirement are both + added as project_managed_components". Converted components pick the managed + one up through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}`` in their REQUIRES. + """ backend = LibraryBackend( platform=ESP32_PLATFORM, framework=_idf_framework(), emit=_emit_idf_component, cache_key="idf", ) - return convert_libraries(libraries, backend) + return convert_libraries(libraries, backend, provided=managed) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..821bd41acb 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -106,3 +106,16 @@ dependencies: version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 1" + # api. Not on Arduino: arduino-esp32 pulls espressif/libsodium, and IDF + # refuses to build two managed components whose names differ only by + # namespace. The Arduino envs get noise-c as a PlatformIO library instead. + esphome/noise-c: + version: 0.1.15 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0" + # Declared even though noise-c depends on it, so that the PlatformIO-library + # converter knows to skip the copy esp_wireguard would otherwise pull in. + esphome/libsodium: + version: 1.10021.2 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0" diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index ee0a758a31..a21a685197 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -689,7 +689,9 @@ def _node_key( def convert_libraries( - libraries: list[Library], backend: LibraryBackend + libraries: list[Library], + backend: LibraryBackend, + provided: set[str] | None = None, ) -> list[ConvertedLibrary]: """Resolve and convert a batch of PlatformIO libraries for ``backend``. @@ -710,28 +712,36 @@ def convert_libraries( ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by short name (part after the ``/``), matched against both the top-level libraries and every dependency discovered during the graph walk. + + ``provided`` names libraries the toolchain already supplies by other means + (for ESP-IDF: registry-managed components declared via + ``add_idf_component``). They are excluded exactly like ``lib_ignore``, so a + library is never both converted and managed -- ESP-IDF refuses to build when + two components claim the same requirement. """ nodes: dict[str, _LibNode] = {} - lib_ignore = { + excluded = { name.split("/")[-1].lower() - for name in CORE.platformio_options.get("lib_ignore", []) + for name in itertools.chain( + CORE.platformio_options.get("lib_ignore", []), provided or () + ) } # The generated build files inside the shared cache bake in the dependency - # wiring, which lib_ignore changes; salt the cache path so configs with - # different lib_ignore values don't fight over (and constantly rewrite) the + # wiring, which the exclusion set changes; salt the cache path so configs + # with different exclusions don't fight over (and constantly rewrite) the # same converted component files. salt = ( - hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] - if lib_ignore + hashlib.sha256(",".join(sorted(excluded)).encode()).hexdigest()[:8] + if excluded else "" ) def is_ignored(name: str | None) -> bool: - if not lib_ignore or name is None: + if not excluded or name is None: return False - return name.split("/")[-1].lower() in lib_ignore + return name.split("/")[-1].lower() in excluded def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, kind, locator = _node_key(name, version, repository) diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..f2cdb07760 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,6 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -77,6 +76,9 @@ lib_compat_mode = strict extends = common lib_deps = ${common.lib_deps} + ; api -- on the ESP-IDF framework this comes from the component registry + ; instead (see esphome/idf_component.yml), so it is not in [common]. + esphome/noise-c@0.1.15 ; api SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt @@ -244,7 +246,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.15 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +643,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.15 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/unit_tests/components/api/test_api_to_code.py b/tests/unit_tests/components/api/test_api_to_code.py new file mode 100644 index 0000000000..a9d562ef2a --- /dev/null +++ b/tests/unit_tests/components/api/test_api_to_code.py @@ -0,0 +1,144 @@ +"""Tests for the noise-c/libsodium library wiring in api's to_code. + +On ESP-IDF (not Arduino) both libraries build themselves as native ESP-IDF +managed components, so they are declared via add_idf_component() instead of +going through ESPHome's PlatformIO-library converter. On every other target +(and on the Arduino framework, where arduino-esp32 brings its own bundled +espressif/libsodium) noise-c still goes through the PlatformIO-library +converter via cg.add_library(). This drives the real to_code() coroutine so +both branches of that decision are exercised end to end, not just mocked. +""" + +from __future__ import annotations + +import asyncio +import base64 +from unittest.mock import MagicMock + +import pytest + +import esphome.codegen as cg +from esphome.components import api, esp32 +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Framework, + Platform, +) +from esphome.core import CORE, ID + + +def _build_config(encryption_key: str) -> dict: + """A minimal, already-validated api config with encryption enabled.""" + return { + api.CONF_ID: ID("api_id", is_declaration=True, type=api.APIServer), + api.CONF_PORT: 6053, + api.CONF_REBOOT_TIMEOUT: cv.positive_time_period_milliseconds("15min"), + api.CONF_BATCH_DELAY: cv.positive_time_period_milliseconds("100ms"), + api.CONF_MAX_CONNECTIONS: 5, + api.CONF_MAX_SEND_QUEUE: 8, + api.CONF_CUSTOM_SERVICES: False, + api.CONF_HOMEASSISTANT_SERVICES: False, + api.CONF_HOMEASSISTANT_STATES: False, + api.CONF_ENCRYPTION: {api.CONF_KEY: encryption_key}, + } + + +@pytest.fixture(name="encryption_key") +def fixture_encryption_key() -> str: + return base64.b64encode(b"0" * 32).decode() + + +def _setup_core(platform: Platform, framework: Framework) -> None: + CORE.reset() + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: str(platform), + KEY_TARGET_FRAMEWORK: str(framework), + } + if platform == Platform.ESP32: + CORE.data[esp32.KEY_ESP32] = {esp32.KEY_VARIANT: "ESP32"} + + +def test_to_code_esp32_idf_encryption_uses_managed_idf_components( + encryption_key: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """On ESP32 + ESP-IDF, noise-c and libsodium are declared as managed IDF + components (add_idf_component), not converted PlatformIO libraries.""" + _setup_core(Platform.ESP32, Framework.ESP_IDF) + config = _build_config(encryption_key) + CORE.component_ids.add("api_id") + + add_idf_component_calls: list[dict] = [] + monkeypatch.setattr( + esp32, + "add_idf_component", + lambda **kwargs: add_idf_component_calls.append(kwargs), + ) + add_library_mock = MagicMock() + monkeypatch.setattr(cg, "add_library", add_library_mock) + + asyncio.run(api.to_code(config)) + + assert add_idf_component_calls == [ + {"name": "esphome/noise-c", "ref": api.NOISE_C_VERSION}, + {"name": "esphome/libsodium", "ref": api.LIBSODIUM_VERSION}, + ] + add_library_mock.assert_not_called() + + +def test_to_code_esp32_arduino_encryption_uses_add_library( + encryption_key: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """On the Arduino framework, arduino-esp32 brings its own bundled + espressif/libsodium, so noise-c must still go through the PlatformIO- + library converter (cg.add_library) instead of add_idf_component().""" + _setup_core(Platform.ESP32, Framework.ARDUINO) + config = _build_config(encryption_key) + CORE.component_ids.add("api_id") + + add_idf_component_mock = MagicMock() + monkeypatch.setattr(esp32, "add_idf_component", add_idf_component_mock) + add_library_calls: list[tuple] = [] + monkeypatch.setattr( + cg, + "add_library", + lambda name, version, repository=None: add_library_calls.append( + (name, version) + ), + ) + + asyncio.run(api.to_code(config)) + + assert add_library_calls == [("esphome/noise-c", api.NOISE_C_VERSION)] + add_idf_component_mock.assert_not_called() + + +def test_to_code_non_esp32_encryption_uses_add_library( + encryption_key: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Off ESP32 entirely (e.g. host), noise-c always goes through the + PlatformIO-library converter -- add_idf_component is ESP-IDF-only.""" + _setup_core(Platform.HOST, Framework.NATIVE) + config = _build_config(encryption_key) + CORE.component_ids.add("api_id") + + add_idf_component_mock = MagicMock() + monkeypatch.setattr(esp32, "add_idf_component", add_idf_component_mock) + add_library_calls: list[tuple] = [] + monkeypatch.setattr( + cg, + "add_library", + lambda name, version, repository=None: add_library_calls.append( + (name, version) + ), + ) + + asyncio.run(api.to_code(config)) + + assert add_library_calls == [("esphome/noise-c", api.NOISE_C_VERSION)] + add_idf_component_mock.assert_not_called() diff --git a/tests/unit_tests/components/test_esp32_write_idf_component_yml.py b/tests/unit_tests/components/test_esp32_write_idf_component_yml.py new file mode 100644 index 0000000000..961893cd9d --- /dev/null +++ b/tests/unit_tests/components/test_esp32_write_idf_component_yml.py @@ -0,0 +1,107 @@ +"""Tests for esp32's _write_idf_component_yml() managed-component wiring. + +A library that is already declared as a managed IDF component (via +add_idf_component(), e.g. api's noise-c/libsodium) must not also be converted +from a PlatformIO library, or ESP-IDF sees the same requirement declared by +two components and refuses to build. _write_idf_component_yml() passes the +set of already-managed component names to generate_idf_components() so the +converter excludes them. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from esphome.components import esp32 +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Framework, + Platform, + Toolchain, +) +from esphome.core import CORE + + +def _setup_core(tmp_path: Path) -> None: + CORE.reset() + CORE.name = "testdevice" + CORE.build_path = tmp_path + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: str(Platform.ESP32), + KEY_TARGET_FRAMEWORK: str(Framework.ESP_IDF), + } + + +def test_write_idf_component_yml_passes_managed_components( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The names already registered via add_idf_component (e.g. noise-c from + api's encryption config) are passed through as ``managed`` so the + PlatformIO-library converter skips them.""" + _setup_core(tmp_path) + CORE.data[esp32.KEY_ESP32] = { + esp32.KEY_COMPONENTS: { + "esphome/noise-c": { + esp32.KEY_REPO: None, + esp32.KEY_REF: "0.1.15", + esp32.KEY_PATH: None, + }, + }, + } + + captured: dict[str, set[str] | None] = {} + + # A converted (non-managed) library the batch still resolves, so the loop + # wiring its override_path into the manifest is exercised for real too. + converted = MagicMock() + converted.get_sanitized_name.return_value = "esphome/other-lib" + converted.path = tmp_path / "pio_components" / "other-lib" + + def fake_generate_idf_components(libraries, managed=None): + captured["managed"] = managed + return [converted] + + monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components) + + esp32._write_idf_component_yml() + + assert captured["managed"] == {"esphome/noise-c"} + # The managed component itself is still written into the manifest deps + # directly (from KEY_COMPONENTS), just not converted a second time. + yml_path = tmp_path / "src" / "idf_component.yml" + assert yml_path.is_file() + contents = yml_path.read_text(encoding="utf-8") + assert "esphome/noise-c" in contents + assert "0.1.15" in contents + # The converted library the batch DID return is still wired in. + assert "esphome/other-lib" in contents + assert str(converted.path) in contents + + +def test_write_idf_component_yml_empty_managed_when_no_components( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No managed components registered yet (no add_idf_component calls) -> + an empty managed set, matching the pre-existing (unfiltered) behavior.""" + _setup_core(tmp_path) + CORE.data[esp32.KEY_ESP32] = {esp32.KEY_COMPONENTS: {}} + + captured: dict[str, set[str] | None] = {} + + def fake_generate_idf_components(libraries, managed=None): + captured["managed"] = managed + return [] + + monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components) + + esp32._write_idf_component_yml() + + assert captured["managed"] == set() diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index cb25535d8d..5cbbc1756c 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -2,10 +2,21 @@ import os from pathlib import Path +from types import SimpleNamespace import pytest +import yaml -from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project +from esphome.espidf import clang_tidy +from esphome.espidf.clang_tidy import ( + _arduino_excluded_stubs, + _convert_pio_libs, + _esphome_manifest_deps, + _Settings, + _setup_core, + _write_tidy_project, +) +import esphome.espidf.component as espidf_component REPO_ROOT = Path(__file__).resolve().parents[2] @@ -64,3 +75,105 @@ def test_setup_core_sets_arduino_env( _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected + + +def test_esphome_manifest_deps_reads_repo_manifest() -> None: + """Returns the top-level dependency names from esphome/idf_component.yml, + independent of any per-dependency framework rules.""" + manifest = yaml.safe_load( + (REPO_ROOT / "esphome" / "idf_component.yml").read_text(encoding="utf-8") + ) + + deps = _esphome_manifest_deps() + + assert isinstance(deps, set) + assert "esphome/noise-c" in deps + assert "esphome/libsodium" in deps + # Cross-check against a fresh parse instead of hardcoding the manifest's + # whole key list, so this doesn't need updating whenever a dependency is + # added or removed. + assert deps == set(manifest["dependencies"]) + + +def test_convert_pio_libs_arduino_framework_passes_empty_managed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """On Arduino, ESPHome's manifest entries for noise-c/libsodium are + rule-gated off (arduino-esp32 brings its own libsodium), so nothing + provides them there -- managed must be empty and they go through the + PlatformIO-library converter as before.""" + monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: []) + + captured: dict[str, set[str] | None] = {} + + # A converted library the batch resolves, so the loop wiring its + # override_path into the returned deps mapping is exercised for real too. + converted = SimpleNamespace( + get_sanitized_name=lambda: "esphome/other-lib", + path=tmp_path / "other-lib", + ) + + def fake_generate_idf_components(libraries, managed=None): + captured["managed"] = managed + return [converted] + + monkeypatch.setattr( + espidf_component, "generate_idf_components", fake_generate_idf_components + ) + + result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino") + + assert captured["managed"] == set() + assert result == { + "esphome/other-lib": {"override_path": str(tmp_path / "other-lib")} + } + + +def test_convert_pio_libs_espidf_framework_passes_manifest_deps( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """On ESP-IDF, libraries ESPHome's own manifest already provides as + managed components (noise-c, libsodium, ...) must be passed through as + ``managed`` so the converter skips them -- converting them too would make + IDF see the same requirement twice.""" + monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: []) + + captured: dict[str, set[str] | None] = {} + + def fake_generate_idf_components(libraries, managed=None): + captured["managed"] = managed + return [] + + monkeypatch.setattr( + espidf_component, "generate_idf_components", fake_generate_idf_components + ) + + result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf") + + assert captured["managed"] == _esphome_manifest_deps() + assert "esphome/noise-c" in captured["managed"] + assert result == {} + + +def test_arduino_excluded_stubs_skips_components_esphome_manifest_provides( + tmp_path: Path, +) -> None: + """A component ESPHome's own idf_component.yml declares for real (e.g. + espressif/lan867x for ethernet) must not be stubbed away -- stubbing it + would silently disable ethernet on Arduino. A component that is only ever + bundled by arduino-esp32 (never in ESPHome's own manifest) still gets a + stub so the arduino-bundled copy doesn't clash with noise-c's libsodium.""" + deps = _arduino_excluded_stubs(tmp_path) + + # lan867x is a real ESPHome dependency (esphome/idf_component.yml), so it + # must be excluded from the stub set. + assert "espressif/lan867x" not in deps + # espressif/libsodium (arduino-esp32's bundled copy) is a different + # package from ESPHome's own esphome/libsodium, so it's still stubbed. + assert "espressif/libsodium" in deps + stub_info = deps["espressif/libsodium"] + assert stub_info["version"] == "*" + stub_path = Path(stub_info["override_path"]) + assert (stub_path / "CMakeLists.txt").is_file() diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f9e048f6f4..8abcab6910 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -876,6 +876,112 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] +def test_generate_idf_components_managed_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # managed (e.g. noise-c/libsodium already declared via add_idf_component) + # must drop B at the top level and C when discovered as a dependency of A, + # exactly like lib_ignore -- neither may be resolved, downloaded, or wired + # into a manifest. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt="", namespace=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.platformio.library, "_resolve_registry_version", fake_resolve + ) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)], + managed={"esphome/B", "esphome/C"}, + ) + + assert [c.name for c in top] == ["esphome/A"] + # Managed libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The managed dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # managed changes the generated wiring just like lib_ignore, so the cache + # path is salted the same way. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + +def test_generate_idf_components_lib_ignore_and_managed_combine_into_salt( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore and managed both contribute to the same exclusion set, so a + # config using both gets a salt reflecting the union of the two sources + # rather than either alone. + manifests = { + "esphome/A": {"name": "A"}, + "esphome/D": {"name": "D"}, + "esphome/E": {"name": "E"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt="", namespace=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.platformio.library, "_resolve_registry_version", fake_resolve + ) + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["D"]}) + + top = generate_idf_components( + [ + Library("esphome/A", "1.0.0", None), + Library("esphome/D", "1.0.0", None), + Library("esphome/E", "1.0.0", None), + ], + managed={"esphome/E"}, + ) + + assert [c.name for c in top] == ["esphome/A"] + assert resolve_calls == ["A"] + # The salt reflects BOTH lib_ignore's "D" and managed's "E" together. + assert download_salts == [hashlib.sha256(b"d,e").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,