diff --git a/Doxyfile b/Doxyfile index 433fcc6c45..f8486e9863 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.5.0b3 +PROJECT_NUMBER = 2026.5.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e9b0f1fd0a..1db97f95eb 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -792,19 +792,15 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -817,7 +813,38 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -825,9 +852,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -843,12 +867,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -860,19 +878,26 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # Official ESP-IDF frameworks don't use the 'extra' semver component. + value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch)) return config @@ -2488,9 +2513,8 @@ def _write_sdkconfig(): def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) + name, _version, path = generate_idf_component(library) dependency["override_path"] = str(path) - dependency["version"] = version return name, dependency diff --git a/esphome/const.py b/esphome/const.py index 3e576c8899..3c5243f304 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.5.0b3" +__version__ = "2026.5.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index b9202fb6bf..b1352f7791 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -154,41 +154,6 @@ class IDFComponent: self.path = self.source.download(self.get_sanitized_name(), force=force) -def _sanitize_version(version: str) -> str: - """ - Sanitize a version string by removing common requirement prefixes or a leading v. - - Args: - version: Version string to clean. - - Returns: - Cleaned version string without common requirement symbols. - """ - version = version.strip() - - prefixes = ( - "^", - "~=", - "~", - ">=", - "<=", - "==", - "!=", - ">", - "<", - "=", - "v", - "V", - ) - - for p in prefixes: - if version.startswith(p): - version = version[len(p) :] - break - - return version.strip() - - def _get_package_from_pio_registry( username: str | None, pkgname: str, requirements: str ) -> tuple[str, str, str | None, str | None]: @@ -396,7 +361,8 @@ def _convert_library_to_component(library: Library) -> IDFComponent: # Repository is provided directly if library.repository: - # Parse repository URL to extract name and version + # Parse repository URL: path becomes the component name, fragment + # becomes the git ref stored on GitSource. split_result = urlsplit(library.repository) if not split_result.fragment.strip(): raise ValueError(f"Missing ref in URL {library.repository}") @@ -405,8 +371,10 @@ def _convert_library_to_component(library: Library) -> IDFComponent: name = str(split_result.path).strip("/") name = name.removesuffix(".git") - # Sanitize version - version = _sanitize_version(split_result.fragment) + # IDF Component Manager only accepts "*", a 40-char commit hash, or + # semver here. The actual git ref is preserved in GitSource.ref; + # override_path makes this field cosmetic at build time. + version = "*" repository = urlunsplit(split_result._replace(fragment="")) source = GitSource(str(repository), split_result.fragment) @@ -619,9 +587,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if description: data["description"] = description - # Do not use the version from library.json/library.properties; it may be incorrect. - data["version"] = component.version - repository = component.data.get("repository", {}).get("url", None) if repository: data["repository"] = repository @@ -631,20 +596,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str: if "dependencies" not in data: data["dependencies"] = {} - # Add this dependency to dependencies - dep = {} - dep["version"] = dependency.version - - # Should use dependency.path as override path - try: - dep["override_path"] = str(dependency.path) - except RuntimeError as e: - # No local path: only a GitSource can substitute its URL. - if not isinstance(dependency.source, GitSource): - raise e - dep["git"] = dependency.source.url - - data["dependencies"][dependency.get_sanitized_name()] = dep + # Every dependency goes through _generate_idf_component → + # component.download() before this runs, so .path is always set. + data["dependencies"][dependency.get_sanitized_name()] = { + "override_path": str(dependency.path), + } return yaml_util.dump(data) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 32bcf4fb3b..aa97c65227 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -786,6 +786,7 @@ def _check_esphome_idf_framework_install( tools: list[str], force: bool = False, env: dict[str, str] | None = None, + source_url: str | None = None, ) -> tuple[Path, bool]: """ Check and install ESP-IDF framework. @@ -796,6 +797,11 @@ def _check_esphome_idf_framework_install( tools: list of tools to install force: If True, force reinstallation env: Optional dictionary of environment variables to set + source_url: Optional override URL for the framework tarball. Supports + the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` / + ``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When + set, it replaces the default mirror list — no implicit fallback, + so a misspelled URL fails loudly. Returns: tuple of (framework_path, install_flag) @@ -817,6 +823,10 @@ def _check_esphome_idf_framework_install( env_stamp_file = framework_path / ESPHOME_STAMP_FILE idf_tools_path = framework_path / "tools" / "idf_tools.py" _LOGGER.info("Checking ESP-IDF %s framework ...", version) + # Logged every invocation (not just on install) so the user can verify the + # override. A changed URL needs ``esphome clean`` to force a re-download. + if source_url: + _LOGGER.info("Using framework source override: %s", source_url) # 2. Download and extract the framework if not already extracted. # The marker is written last after extraction succeeds, so its presence @@ -844,9 +854,8 @@ def _check_esphome_idf_framework_install( except ValueError: pass - download_from_mirrors( - ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file - ) + mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS + download_from_mirrors(mirrors, substitutions, tmp.file) _LOGGER.info("Extracting ESP-IDF %s framework ...", version) archive_extract_all(tmp.file, framework_path, progress_header="Extracting") @@ -1008,6 +1017,7 @@ def check_esp_idf_install( tools: list[str] | None = None, features: list[str] | None = None, force: bool = False, + source_url: str | None = None, ) -> tuple[Path, Path]: """ Check and install ESP-IDF framework and Python environment. @@ -1018,6 +1028,10 @@ def check_esp_idf_install( tools: list of tools to install features: Features to install force: If True, force reinstallation + source_url: Optional override URL for the framework tarball. When + set, it replaces the default mirror list (no fallback). Forwarded + to ``_check_esphome_idf_framework_install``; supports the same URL + substitutions. Returns: tuple of (framework_path, python_env_path) @@ -1040,7 +1054,7 @@ def check_esp_idf_install( # 1) Framework framework_path, installed = _check_esphome_idf_framework_install( - version, targets, tools, force=force, env=env + version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e0bc5bb393..ef28575caa 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary @@ -37,13 +38,27 @@ def _get_core_framework_version(): return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION]) +def _get_framework_source_override() -> str | None: + """Return the user-supplied esp32.framework.source override, if any. + + The override lets a user point the IDF tarball download at a custom URL + (mirror, fork, local server). Substitutions like ``{VERSION}`` / + ``{MAJOR}`` etc. work the same as in the default mirror list. + """ + if CORE.config is None: + return None + return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: version = version or _get_core_framework_version() paths = _cache().paths if version not in paths: - paths[version] = check_esp_idf_install(version) + paths[version] = check_esp_idf_install( + version, source_url=_get_framework_source_override() + ) return paths[version] diff --git a/requirements.txt b/requirements.txt index 3c66db489a..4338063387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,8 +12,8 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==45.0.3 -zeroconf==0.149.7 +aioesphomeapi==45.0.4 +zeroconf==0.149.12 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 8977b05d23..373432f7d2 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) - assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n" + assert result == "description: test\nrepository: http://aaa\n" def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): @@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path): assert ( result - == f"""version: 1.0.0 -dependencies: + == f"""dependencies: dep: - version: '1.0' override_path: {dep.path} """ ) -def test_generate_idf_component_yml_missing_path_reraises(tmp_component): - # A dep without a path and without a recognised source should re-raise - # the underlying RuntimeError instead of silently producing a bad manifest. +def test_generate_idf_component_yml_missing_path_raises(tmp_component): + # A dep without a path is a contract violation — every dep is expected + # to have been downloaded before YAML generation. Raise loudly. dep = IDFComponent("foo/bar", "1.0", source=None) tmp_component.dependencies = [dep] @@ -422,8 +420,20 @@ def test_convert_library_with_repository(): result = _convert_library_to_component(lib) assert result.name == "foo/bar" - assert result.version == "1.2.3" + assert result.version == "*" assert isinstance(result.source, GitSource) + assert result.source.ref == "v1.2.3" + + +def test_convert_library_with_branch_ref(): + lib = Library("name", None, "https://github.com/foo/bar.git#some-branch") + + result = _convert_library_to_component(lib) + + assert result.name == "foo/bar" + assert result.version == "*" + assert isinstance(result.source, GitSource) + assert result.source.ref == "some-branch" def test_convert_library_missing_ref(): diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py new file mode 100644 index 0000000000..adc8bfce63 --- /dev/null +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -0,0 +1,58 @@ +"""Tests for esphome.espidf.toolchain helpers.""" + +# pylint: disable=protected-access + +from unittest.mock import patch + +from esphome.const import CONF_FRAMEWORK, CONF_SOURCE +from esphome.core import CORE +from esphome.espidf import toolchain + + +def test_get_framework_source_override_no_config(): + """When CORE.config hasn't been set, no override is returned.""" + CORE.config = None + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_esp32_section(): + """A config without an esp32 section yields no override.""" + CORE.config = {} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_no_framework_source(): + """An esp32 section without framework.source yields no override.""" + CORE.config = {"esp32": {CONF_FRAMEWORK: {}}} + assert toolchain._get_framework_source_override() is None + + +def test_get_framework_source_override_returns_value(): + """A user-supplied framework source is returned verbatim.""" + url = "https://example.com/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + assert toolchain._get_framework_source_override() == url + + +def test_get_esphome_esp_idf_paths_forwards_source_override(): + """_get_esphome_esp_idf_paths threads the override into check_esp_idf_install.""" + url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz" + CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}} + # Hit a fresh cache key so check_esp_idf_install is actually called. + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=url) + + +def test_get_esphome_esp_idf_paths_no_override(): + """When no source override is configured, source_url=None is passed.""" + CORE.config = {} + toolchain._cache().paths.clear() + with patch.object( + toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") + ) as mock_install: + toolchain._get_esphome_esp_idf_paths("5.5.4") + mock_install.assert_called_once_with("5.5.4", source_url=None)