[noise] Import noise-c and libsodium as ESP-IDF managed components (#18351)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Jesse Hills
2026-10-01 12:59:00 -05:00
committed by GitHub
co-authored by Jonathan Swoboda J. Nick Koston
parent 879c5cfc5b
commit 5e67258818
11 changed files with 726 additions and 27 deletions
@@ -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
@@ -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
+158 -1
View File
@@ -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
+106
View File
@@ -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,