diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c3da73f510..49a115c9c5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -322,6 +322,34 @@ ARDUINO_EXCLUDED_IDF_COMPONENTS = ( "joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem ) +# Entries arduino-esp32 only declares below the given IDF version; stubbing one past +# it clashes with ESPHome's own managed component of the same short name. +ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF: dict[str, cv.Version] = { + "espressif__libsodium": cv.Version(6, 0, 0), +} + + +def arduino_bundles_libsodium() -> bool: + """arduino-esp32 ships its own libsodium below IDF 6.0.""" + return ( + CORE.using_arduino + and idf_version() + < ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF["espressif__libsodium"] + ) + + +def arduino_excluded_idf_components() -> set[str]: + """The arduino-bundled components to stub for this build's IDF version.""" + version = idf_version() + return { + component + for component in ARDUINO_EXCLUDED_IDF_COMPONENTS + if (max_version := ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF.get(component)) + is None + or version < max_version + } + + # Mapping of Arduino libraries to IDF managed components they require # When an Arduino library is enabled via cg.add_library(), these components # are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS. @@ -3513,9 +3541,7 @@ def _write_idf_component_yml(): } # Only stub components that are not required by any enabled Arduino library - components_to_stub = ( - set(ARDUINO_EXCLUDED_IDF_COMPONENTS) - required_idf_components - ) + components_to_stub = arduino_excluded_idf_components() - required_idf_components stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) @@ -3580,7 +3606,11 @@ 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 also declared as a managed component is not converted too, or + # IDF sees the same requirement twice; converted components reach it through + # ${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/components/noise/__init__.py b/esphome/components/noise/__init__.py index c894c147cd..19ad36612d 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -12,6 +12,11 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "noise" +# Keep in sync with platformio.ini and esphome/idf_component.yml. +# LIBSODIUM_VERSION must match the version noise-c pins in its manifests. +NOISE_C_VERSION = "0.1.30" +LIBSODIUM_VERSION = "1.10021.11" + noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) @@ -92,6 +97,22 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: return ENCRYPTION_SCHEMA(config) +def _use_managed_components() -> bool: + """Whether noise-c and libsodium come from the ESP-IDF component registry. + + Both build as ESP-IDF components, so on ESP32 they skip the PlatformIO library + converter unless arduino-esp32 bundles its own libsodium. Not toolchain + dependent: the PlatformIO toolchain reads the project manifest too, and every + consumer of libsodium must make the same choice or a second copy appears. + """ + if not CORE.is_esp32: + return False + + from esphome.components.esp32 import arduino_bundles_libsodium + + return not arduino_bundles_libsodium() + + def enable_spare_ephemeral() -> None: """Compile the spare ephemeral key slot; the component that refills it calls this.""" cg.add_define("USE_NOISE_SPARE_EPHEMERAL") @@ -99,12 +120,16 @@ def enable_spare_ephemeral() -> None: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.30") - # noise-c depends on libsodium, but declaring it here too lets the - # library manager see the full set up front instead of discovering - # libsodium only after noise-c has downloaded, so the two can download - # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.11") + # libsodium is declared next to noise-c so the library manager sees both up front + # and nothing else pulls a second copy; the version must match noise-c's own pin + if _use_managed_components(): + from esphome.components.esp32 import add_idf_component + + add_idf_component(name="esphome/noise-c", ref=NOISE_C_VERSION) + add_idf_component(name="esphome/libsodium", ref=LIBSODIUM_VERSION) + else: + cg.add_library("esphome/noise-c", NOISE_C_VERSION) + cg.add_library("esphome/libsodium", LIBSODIUM_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/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 3e1a510dee..623b352e97 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -238,6 +238,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]]: @@ -250,12 +261,19 @@ 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 the manifest already provides as managed components are skipped, as + in the real esp32 build; converting them too would give IDF the same requirement + twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert. """ + from esphome.components.esp32 import arduino_bundles_libsodium from esphome.espidf.component import generate_idf_components libraries = _parse_lib_deps(platformio_ini, framework) + # Same rule as noise._use_managed_components and the manifest + managed = set() if arduino_bundles_libsodium() 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 @@ -273,24 +291,18 @@ 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, + arduino_excluded_idf_components, ) - 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) deps: dict[str, dict] = {} - for component in sorted(ARDUINO_EXCLUDED_IDF_COMPONENTS): + for component in sorted(arduino_excluded_idf_components()): if _idf_component_dep_name(component) in esphome_deps: continue # ESPHome needs this one for real (don't stub it away) stub_path = stubs_dir / _idf_component_stub_name(component) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index bed999656b..c53dbe17db 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -287,12 +287,19 @@ 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 registry components already in the project manifest; they + are not converted, since IDF rejects the same requirement from two components. + Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``. + """ 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 1a972896c3..fc8090a322 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -106,3 +106,14 @@ dependencies: version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 1" + # noise (api, ota). Arduino below IDF 6.0 bundles espressif/libsodium, which + # clashes with esphome/libsodium, so those builds use the PlatformIO libraries. + esphome/noise-c: + version: 0.1.30 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" + # Declared alongside noise-c so the library converter skips esp_wireguard's copy + esphome/libsodium: + version: 1.10021.11 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index e5e4aa7245..12aaae315e 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -1120,7 +1120,9 @@ def _prefetch_wave( 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``. @@ -1141,10 +1143,18 @@ 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 supplies by other means (for ESP-IDF, + managed components from ``add_idf_component``); unlike ``backend.provides`` they + carry an owner. They are excluded like ``lib_ignore`` so nothing is both + converted and managed, which ESP-IDF refuses to build. """ nodes: dict[str, _LibNode] = {} - lib_ignore = lib_ignore_set() + # Folded into one set so every is_lib_ignored() call site honors both. + lib_ignore = lib_ignore_set() | { + name.split("/")[-1].lower() for name in 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 diff --git a/platformio.ini b/platformio.ini index dd2a572493..00fad1ff66 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.30 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -77,6 +76,10 @@ lib_compat_mode = strict extends = common lib_deps = ${common.lib_deps} + ; noise (api, ota) -- on the ESP-IDF framework these come from the + ; component registry instead (see esphome/idf_component.yml), so they + ; are not in [common]. + esphome/noise-c@0.1.30 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt diff --git a/tests/unit_tests/components/noise/test_noise_to_code.py b/tests/unit_tests/components/noise/test_noise_to_code.py new file mode 100644 index 0000000000..a7e0a45e5c --- /dev/null +++ b/tests/unit_tests/components/noise/test_noise_to_code.py @@ -0,0 +1,163 @@ +"""Tests for the noise-c/libsodium library wiring in the noise component. + +Drives the real to_code() so every branch of the managed-versus-converted +decision runs end to end. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +import esphome.codegen as cg +from esphome.components import esp32, noise +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Framework, + Platform, + Toolchain, +) +from esphome.core import CORE + +DEFAULT_IDF_VERSION = cv.Version(5, 5, 4) + + +def _setup_core( + platform: Platform, + framework: Framework, + toolchain: Toolchain, + idf_version: cv.Version = DEFAULT_IDF_VERSION, +) -> None: + CORE.reset() + CORE.toolchain = toolchain + 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", + esp32.KEY_IDF_VERSION: idf_version, + } + + +def _record_calls( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[list[dict], list[tuple]]: + """Capture both wiring paths so each test can assert one ran and one did not.""" + idf_calls: list[dict] = [] + lib_calls: list[tuple] = [] + monkeypatch.setattr( + esp32, "add_idf_component", lambda **kwargs: idf_calls.append(kwargs) + ) + monkeypatch.setattr( + cg, + "add_library", + lambda name, version, repository=None: lib_calls.append((name, version)), + ) + return idf_calls, lib_calls + + +@pytest.mark.parametrize("toolchain", [Toolchain.ESP_IDF, Toolchain.PLATFORMIO]) +def test_to_code_esp32_idf_uses_managed_idf_components( + toolchain: Toolchain, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ESP32 + ESP-IDF declares both as managed IDF components on either toolchain.""" + _setup_core(Platform.ESP32, Framework.ESP_IDF, toolchain) + idf_calls, lib_calls = _record_calls(monkeypatch) + + asyncio.run(noise.to_code({})) + + assert idf_calls == [ + {"name": "esphome/noise-c", "ref": noise.NOISE_C_VERSION}, + {"name": "esphome/libsodium", "ref": noise.LIBSODIUM_VERSION}, + ] + assert lib_calls == [] + + +def test_to_code_esp32_arduino_below_idf6_uses_add_library( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Arduino below IDF 6.0 bundles espressif/libsodium, so both stay PlatformIO + libraries.""" + _setup_core( + Platform.ESP32, Framework.ARDUINO, Toolchain.ESP_IDF, cv.Version(5, 5, 4) + ) + idf_calls, lib_calls = _record_calls(monkeypatch) + + asyncio.run(noise.to_code({})) + + assert lib_calls == [ + ("esphome/noise-c", noise.NOISE_C_VERSION), + ("esphome/libsodium", noise.LIBSODIUM_VERSION), + ] + assert idf_calls == [] + + +@pytest.mark.parametrize("idf_version", [cv.Version(6, 0, 0), cv.Version(6, 1, 0)]) +def test_to_code_esp32_arduino_idf6_uses_managed_idf_components( + idf_version: cv.Version, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """From IDF 6.0 there is no clash, so Arduino uses managed components too.""" + _setup_core(Platform.ESP32, Framework.ARDUINO, Toolchain.ESP_IDF, idf_version) + idf_calls, lib_calls = _record_calls(monkeypatch) + + asyncio.run(noise.to_code({})) + + assert idf_calls == [ + {"name": "esphome/noise-c", "ref": noise.NOISE_C_VERSION}, + {"name": "esphome/libsodium", "ref": noise.LIBSODIUM_VERSION}, + ] + assert lib_calls == [] + + +def test_to_code_non_esp32_uses_add_library( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Off ESP32 entirely (e.g. host) there are no IDF components at all.""" + _setup_core(Platform.HOST, Framework.NATIVE, Toolchain.PLATFORMIO) + idf_calls, lib_calls = _record_calls(monkeypatch) + + asyncio.run(noise.to_code({})) + + assert lib_calls == [ + ("esphome/noise-c", noise.NOISE_C_VERSION), + ("esphome/libsodium", noise.LIBSODIUM_VERSION), + ] + assert idf_calls == [] + + +def test_versions_match_the_repo_manifests() -> None: + """A bump that misses one of the duplicated pins would ship two libsodium versions.""" + from pathlib import Path + + import yaml + + repo_root = Path(__file__).resolve().parents[4] + manifest = yaml.safe_load( + (repo_root / "esphome" / "idf_component.yml").read_text(encoding="utf-8") + ) + deps = manifest["dependencies"] + + assert deps["esphome/noise-c"]["version"] == noise.NOISE_C_VERSION + assert deps["esphome/libsodium"]["version"] == noise.LIBSODIUM_VERSION + # Both are skipped on Arduino below IDF 6.0, where the PlatformIO library + # path is used instead (see noise._use_managed_components). + for name in ("esphome/noise-c", "esphome/libsodium"): + assert deps[name]["rules"] == [ + {"if": "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0"} + ] + # Every noise-c pin in platformio.ini, not just one of them + pins = re.findall( + r"esphome/noise-c@(\S+)", + (repo_root / "platformio.ini").read_text(encoding="utf-8"), + ) + assert set(pins) == {noise.NOISE_C_VERSION} + assert len(pins) >= 1 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..931de627a1 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_write_idf_component_yml.py @@ -0,0 +1,175 @@ +"""Tests for esp32's _write_idf_component_yml() managed-component wiring. + +A library already declared as a managed IDF component must not also be converted, +or ESP-IDF sees the same requirement twice and refuses to build. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from esphome.components import esp32 +import esphome.config_validation as cv +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: + """Names registered via add_idf_component are passed as ``managed`` so the + 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 add_idf_component calls means an empty managed set, the old 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() + + +@pytest.mark.parametrize( + ("version", "libsodium_stubbed"), + [ + (cv.Version(5, 5, 4), True), + (cv.Version(5, 99, 99), True), + (cv.Version(6, 0, 0), False), + (cv.Version(6, 1, 0), False), + ], +) +def test_arduino_excluded_idf_components_depends_on_idf_version( + version: cv.Version, libsodium_stubbed: bool +) -> None: + """espressif/libsodium is stubbed below IDF 6.0 only; unmapped entries always.""" + CORE.reset() + CORE.data[esp32.KEY_ESP32] = {esp32.KEY_IDF_VERSION: version} + + excluded = esp32.arduino_excluded_idf_components() + + assert ("espressif__libsodium" in excluded) is libsodium_stubbed + assert "espressif__cbor" in excluded + + +@pytest.mark.parametrize( + ("framework", "version", "bundled"), + [ + (Framework.ARDUINO, cv.Version(5, 5, 5), True), + (Framework.ARDUINO, cv.Version(6, 0, 0), False), + (Framework.ESP_IDF, cv.Version(5, 5, 5), False), + ], +) +def test_arduino_bundles_libsodium( + framework: Framework, version: cv.Version, bundled: bool, tmp_path: Path +) -> None: + """Only Arduino below IDF 6.0 brings its own libsodium.""" + _setup_core(tmp_path) + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(framework) + CORE.data[esp32.KEY_ESP32] = {esp32.KEY_IDF_VERSION: version} + + assert esp32.arduino_bundles_libsodium() is bundled + + +@pytest.mark.parametrize( + ("version", "libsodium_stubbed"), + [(cv.Version(5, 5, 4), True), (cv.Version(6, 0, 0), False)], +) +def test_write_idf_component_yml_arduino_stubs_follow_idf_version( + version: cv.Version, + libsodium_stubbed: bool, + tmp_path: Path, +) -> None: + """The manifest stubs follow the IDF version: espressif/libsodium below 6.0 only.""" + _setup_core(tmp_path) + CORE.toolchain = Toolchain.PLATFORMIO + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO) + CORE.data[esp32.KEY_ESP32] = { + esp32.KEY_COMPONENTS: {}, + esp32.KEY_IDF_VERSION: version, + esp32.KEY_ARDUINO_LIBRARIES: set(), + } + + esp32._write_idf_component_yml() + + contents = (tmp_path / "src" / "idf_component.yml").read_text(encoding="utf-8") + assert ("espressif/libsodium" in contents) is libsodium_stubbed + assert "espressif/cbor" in contents + stub_dir = ( + tmp_path + / "component_stubs" + / esp32._idf_component_stub_name("espressif__libsodium") + ) + assert stub_dir.is_dir() is libsodium_stubbed diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index e0cfec88c8..b0d016b1b4 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -5,12 +5,26 @@ import json import os from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch import pytest +import yaml +from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE from esphome.espidf import clang_tidy -from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project +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] @@ -137,3 +151,146 @@ def test_generate_compile_commands_configures_twice( ) assert result == tmp_path / "build" / "compile_commands.json" assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"] + + +def test_esphome_manifest_deps_reads_repo_manifest() -> None: + """Top-level dependency names, independent of the per-dependency 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: + """Below IDF 6.0 the manifest gates noise-c/libsodium off on Arduino, so they + still go through the converter.""" + _set_idf_version(cv.Version(5, 5, 5)) + 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_arduino_idf_6_passes_manifest_deps( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """From IDF 6.0 the manifest enables them on Arduino too, so the converter + must skip them.""" + _set_idf_version(cv.Version(6, 0, 0)) + 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 + ) + + assert _convert_pio_libs(tmp_path / "platformio.ini", "arduino") == {} + assert captured["managed"] == _esphome_manifest_deps() + + +def test_convert_pio_libs_espidf_framework_passes_manifest_deps( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Manifest-provided components are passed as ``managed`` so the converter + skips them.""" + _set_idf_version(cv.Version(5, 5, 5), "espidf") + 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 _set_idf_version(version: cv.Version, framework: str = "arduino") -> None: + CORE.reset() + CORE.data[KEY_ESP32] = {KEY_IDF_VERSION: version} + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: framework, + } + + +def test_arduino_excluded_stubs_skips_components_esphome_manifest_provides( + tmp_path: Path, +) -> None: + """A component ESPHome's own manifest declares (espressif/lan867x for ethernet) + is not stubbed; one only arduino-esp32 bundles still is.""" + _set_idf_version(cv.Version(5, 5, 4)) + + 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 below IDF 6.0, where + # arduino-esp32 still declares it, it is 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() + + +def test_arduino_excluded_stubs_skips_libsodium_from_idf_6(tmp_path: Path) -> None: + """From IDF 6.0 arduino-esp32 drops espressif/libsodium; a stub would clash + with esphome/libsodium.""" + _set_idf_version(cv.Version(6, 0, 0)) + + deps = _arduino_excluded_stubs(tmp_path) + + assert "espressif/libsodium" not in deps + # Other arduino-bundled components are still stubbed. + assert "espressif/cbor" in deps diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 3789eefc64..67da0f4f6d 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -803,6 +803,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", None + + 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", None + + 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,