862 lines
29 KiB
Python
862 lines
29 KiB
Python
from collections.abc import Callable
|
|
import glob
|
|
import hashlib
|
|
import itertools
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import tempfile
|
|
from typing import TypeVar
|
|
from urllib.parse import urlparse, urlsplit, urlunsplit
|
|
|
|
from esphome import git, yaml_util
|
|
from esphome.core import CORE, Library
|
|
from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir
|
|
from esphome.helpers import write_file_if_changed
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PathType = str | os.PathLike
|
|
|
|
#
|
|
# Constants from platformio
|
|
#
|
|
|
|
FILTER_REGEX = re.compile(r"([+-])<([^>]+)>")
|
|
DEFAULT_BUILD_SRC_FILTER = (
|
|
"+<*> -<.git/> -<.svn/> -<example/> -<examples/> -<test/> -<tests/>"
|
|
)
|
|
DEFAULT_BUILD_SRC_DIRS = "src"
|
|
DEFAULT_BUILD_INCLUDE_DIR = "include"
|
|
DEFAULT_BUILD_FLAGS = []
|
|
SRC_FILE_EXTENSIONS = [
|
|
".c",
|
|
".cpp",
|
|
".cc",
|
|
".cxx",
|
|
".c++",
|
|
".S",
|
|
".spp",
|
|
".SPP",
|
|
".sx",
|
|
".s",
|
|
".asm",
|
|
".ASM",
|
|
]
|
|
|
|
ESP32_PLATFORM = "espressif32"
|
|
DOMAIN = "pio_components"
|
|
|
|
ESPHOME_DATA_KEY = "ESPHOME"
|
|
ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
|
|
|
|
|
|
class Source:
|
|
def download(self, dir_suffix: str, force: bool = False) -> Path:
|
|
raise NotImplementedError()
|
|
|
|
|
|
class URLSource(Source):
|
|
def __init__(self, url: str):
|
|
self.url = url
|
|
|
|
def download(self, dir_suffix: str, force: bool = False) -> Path:
|
|
base_dir = Path(CORE.data_dir) / DOMAIN
|
|
h = hashlib.new("sha256")
|
|
h.update(self.url.encode())
|
|
path = base_dir / h.hexdigest()[:8] / dir_suffix
|
|
# Marker file written last to signal a complete extraction. Using a
|
|
# marker (instead of just `path.is_dir()`) means an interrupted
|
|
# extraction is correctly detected and re-run on the next invocation,
|
|
# and lets us extract directly into ``path`` — avoiding a
|
|
# post-extraction rename that races with antivirus on Windows.
|
|
extracted_marker = path / ".esphome_extracted"
|
|
if not extracted_marker.is_file() or force:
|
|
rmdir(path, msg=f"Clean up library directory {path}")
|
|
|
|
# Download in temporary file
|
|
with tempfile.NamedTemporaryFile() as tmp:
|
|
_LOGGER.info("Downloading %s ...", self.url)
|
|
_LOGGER.debug("Location: %s", path)
|
|
|
|
download_from_mirrors([self.url], {}, tmp.file)
|
|
|
|
_LOGGER.debug("Extracting archive to %s ...", path)
|
|
archive_extract_all(tmp.file, path)
|
|
extracted_marker.touch()
|
|
return path
|
|
|
|
def __str__(self):
|
|
return self.url
|
|
|
|
|
|
class GitSource(Source):
|
|
def __init__(self, url: str, ref: str | None):
|
|
self.url = url
|
|
self.ref = ref
|
|
|
|
def download(self, dir_suffix: str, force: bool = False) -> Path:
|
|
path, _ = git.clone_or_update(
|
|
url=self.url,
|
|
ref=self.ref,
|
|
refresh=git.NEVER_REFRESH if not force else None,
|
|
domain=DOMAIN,
|
|
submodules=[],
|
|
subpath=Path(dir_suffix),
|
|
)
|
|
return path
|
|
|
|
def __str__(self):
|
|
return f"{self.url}#{self.ref}" if self.ref else self.url
|
|
|
|
|
|
class InvalidIDFComponent(Exception):
|
|
pass
|
|
|
|
|
|
class IDFComponent:
|
|
def __init__(self, name: str, version: str, source: Source | None):
|
|
self.name = name
|
|
self.version = version
|
|
self.source = source
|
|
self.data = {}
|
|
self.dependencies: list[IDFComponent] = []
|
|
self._path: Path | None = None
|
|
|
|
def __str__(self):
|
|
return f"{self.name}@{self.version}={self.source}"
|
|
|
|
@property
|
|
def path(self) -> Path:
|
|
if self._path is None:
|
|
raise RuntimeError(f"path not set for component {self}")
|
|
return self._path
|
|
|
|
@path.setter
|
|
def path(self, value: Path) -> None:
|
|
self._path = value
|
|
|
|
def get_sanitized_name(self):
|
|
return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name)
|
|
|
|
def get_require_name(self):
|
|
return self.get_sanitized_name().replace("/", "__")
|
|
|
|
def download(self, force: bool = False):
|
|
"""
|
|
The dependency name should match the directory name at the end of the override path.
|
|
The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name.
|
|
If you want to specify the full name of the component with the namespace, replace / in the component name with __.
|
|
@see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html
|
|
"""
|
|
self.path = self.source.download(self.get_sanitized_name(), force=force)
|
|
|
|
|
|
def _get_package_from_pio_registry(
|
|
username: str | None, pkgname: str, requirements: str
|
|
) -> tuple[str, str, str | None, str | None]:
|
|
"""
|
|
Fetch package information from PlatformIO registry.
|
|
|
|
This function queries the PlatformIO registry to find a library package
|
|
that matches the given criteria and returns its metadata including version
|
|
and download URL.
|
|
|
|
Args:
|
|
username: The owner/username of the package (can be None)
|
|
pkgname: The name of the package
|
|
requirements: Version requirements (e.g., "^1.0.0")
|
|
|
|
Returns:
|
|
tuple[str, str, str | None, str | None]:
|
|
A tuple containing (owner, name, version, download_url)
|
|
where version and download_url can be None if not found
|
|
"""
|
|
|
|
from platformio.package.manager._registry import PackageManagerRegistryMixin
|
|
from platformio.package.meta import PackageSpec
|
|
|
|
# Create a minimal PackageManagerRegistry class
|
|
class PackageManagerRegistry(PackageManagerRegistryMixin):
|
|
def __init__(self):
|
|
self._registry_client = None
|
|
self.pkg_type = "library"
|
|
|
|
@staticmethod
|
|
def is_system_compatible(value, custom_system=None):
|
|
return True
|
|
|
|
pio_registry = PackageManagerRegistry()
|
|
|
|
# Fetch package metadata from registry
|
|
package = pio_registry.fetch_registry_package(
|
|
PackageSpec(
|
|
owner=username,
|
|
name=pkgname,
|
|
)
|
|
)
|
|
owner = package["owner"]["username"]
|
|
name = package["name"]
|
|
|
|
# Find the best matching version based on requirements
|
|
version = pio_registry.pick_best_registry_version(
|
|
package.get("versions"),
|
|
PackageSpec(owner=username, name=pkgname, requirements=requirements),
|
|
)
|
|
|
|
# If no version found, return with None for version and URL
|
|
if not version:
|
|
return owner, name, None, None
|
|
|
|
# Find the compatible package file for this version
|
|
pkgfile = pio_registry.pick_compatible_pkg_file(version["files"])
|
|
|
|
# If no package file found, return with None for URL but valid version
|
|
if not pkgfile:
|
|
return owner, name, version["name"], None
|
|
|
|
return owner, name, version["name"], pkgfile["download_url"]
|
|
|
|
|
|
def _apply_extra_script(component: IDFComponent) -> None:
|
|
"""Run a PIO ``extraScript`` and fold its captured env vars into
|
|
``component.data["build"]["flags"]`` so the existing -L/-l/-D
|
|
extraction in ``generate_cmakelists_txt`` picks them up."""
|
|
extra_script = component.data.get("build", {}).get("extraScript")
|
|
if not extra_script:
|
|
return
|
|
# Resolve and confine to the component dir so a malicious library.json
|
|
# can't escape (e.g. ``"extraScript": "../../etc/passwd"``).
|
|
library_root = component.path.resolve()
|
|
script_path = (component.path / extra_script).resolve()
|
|
if not script_path.is_relative_to(library_root) or not script_path.is_file():
|
|
return
|
|
from esphome.components.esp32 import get_esp32_variant
|
|
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
|
|
|
|
idf_target = get_esp32_variant().lower().replace("-", "")
|
|
result = run_extra_script(
|
|
script_path, library_dir=component.path, idf_target=idf_target
|
|
)
|
|
extra_flags = captured_as_build_flags(result, library_dir=component.path)
|
|
if not extra_flags:
|
|
return
|
|
flags = component.data.setdefault("build", {}).setdefault("flags", [])
|
|
if isinstance(flags, str):
|
|
flags = [flags]
|
|
flags.extend(extra_flags)
|
|
component.data["build"]["flags"] = flags
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
def _ensure_list(obj: T | list[T]) -> list[T]:
|
|
"""
|
|
Convert an object to a list if it isn't already a list.
|
|
|
|
Args:
|
|
obj: Object that may or may not already be a list.
|
|
|
|
Returns:
|
|
list[T]: The original list if ``obj`` is a list, otherwise a single-item
|
|
list containing ``obj``.
|
|
"""
|
|
return [obj] if not isinstance(obj, list) else obj
|
|
|
|
|
|
def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str:
|
|
"""
|
|
Convert owner and package name to a standardized component name.
|
|
|
|
This function combines owner and package name with a forward slash when
|
|
both are provided, otherwise returns just the package name.
|
|
|
|
Args:
|
|
owner: The owner/username of the package (can be None)
|
|
pkgname: The name of the package
|
|
|
|
Returns:
|
|
str: The standardized component name in "owner/pkgname" format or just "pkgname"
|
|
"""
|
|
return f"{owner}/{pkgname}" if owner else pkgname
|
|
|
|
|
|
def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]:
|
|
"""
|
|
Recursively match files in a directory according to include/exclude patterns.
|
|
|
|
This function processes a list of filter strings that indicate which files
|
|
to include or exclude. Each filter is parsed into patterns with a sign:
|
|
'+' for inclusion and '-' for exclusion. Directory patterns ending with '/'
|
|
are normalized to include all their contents recursively.
|
|
|
|
Args:
|
|
src_dir (PathType): Root directory to search within.
|
|
src_filters (list[str]): List of filter strings, which may contain multiple
|
|
patterns. Each pattern can start with '+' or '-' to indicate inclusion
|
|
or exclusion.
|
|
|
|
Returns:
|
|
list[str]: List of matched file paths as strings. Only files (not directories)
|
|
are returned, even if a directory matches a pattern.
|
|
"""
|
|
matches = list(
|
|
itertools.chain.from_iterable(
|
|
FILTER_REGEX.findall(src_filter) for src_filter in src_filters
|
|
)
|
|
)
|
|
|
|
selected = set()
|
|
|
|
for sign, pattern in matches:
|
|
pattern = pattern.strip()
|
|
|
|
if pattern.endswith("/"):
|
|
pattern = pattern.rstrip("/") + "/**"
|
|
|
|
full_pattern = os.path.join(glob.escape(str(src_dir)), pattern)
|
|
|
|
matched = []
|
|
for item in glob.glob(full_pattern, recursive=True):
|
|
if not os.path.isdir(item):
|
|
matched.append(item)
|
|
else:
|
|
# PlatformIO quirk: a directory matched with "*" should include all its
|
|
# nested files and subdirectories, not just the directory itself.
|
|
for root, _, files in os.walk(item):
|
|
matched.extend([os.path.join(root, f) for f in files])
|
|
|
|
if sign == "+":
|
|
selected.update(matched)
|
|
elif sign == "-":
|
|
selected.difference_update(matched)
|
|
|
|
return [r for r in selected if os.path.isfile(r)]
|
|
|
|
|
|
def _convert_library_to_component(library: Library) -> IDFComponent:
|
|
"""
|
|
Convert a Library object to an IDFComponent object by resolving its metadata.
|
|
|
|
This function handles the conversion of library specifications to component
|
|
objects, resolving versions through PlatformIO registry when needed or
|
|
parsing direct repository URLs.
|
|
|
|
Args:
|
|
library: The Library object containing name, version, and/or repository information
|
|
|
|
Returns:
|
|
IDFComponent: The resolved component with name, version, and URL
|
|
|
|
Raises:
|
|
RuntimeError: If no artifact can be found for the library
|
|
"""
|
|
name = None
|
|
version = None
|
|
source = None
|
|
|
|
# Repository is provided directly
|
|
if library.repository:
|
|
# Parse repository URL: path becomes the component name, fragment
|
|
# (if any) becomes the git ref stored on GitSource. A missing
|
|
# fragment is fine -- clone_or_update leaves the depth-1 clone on
|
|
# the remote's default branch, matching PIO's lib_deps behavior
|
|
# and external_components handling.
|
|
split_result = urlsplit(library.repository)
|
|
|
|
# Sanitize name
|
|
name = str(split_result.path).strip("/")
|
|
name = name.removesuffix(".git")
|
|
|
|
# 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=""))
|
|
|
|
ref = split_result.fragment.strip() or None
|
|
source = GitSource(str(repository), ref)
|
|
|
|
# Version is provided - resolve using PlatformIO registry
|
|
elif library.version:
|
|
name = library.name
|
|
if "/" not in name:
|
|
owner, pkgname = None, name
|
|
else:
|
|
owner, pkgname = name.split("/", 1)
|
|
|
|
owner, pkgname, version, url = _get_package_from_pio_registry(
|
|
owner, pkgname, library.version
|
|
)
|
|
if url is None:
|
|
raise RuntimeError(
|
|
f"Can't find an pkg file from PlatformIO registry for library {library}"
|
|
)
|
|
|
|
name = _owner_pkgname_to_name(owner, pkgname)
|
|
source = URLSource(url)
|
|
|
|
if source is None:
|
|
raise RuntimeError(f"Can't find an artifact associated to library {library}")
|
|
|
|
assert name, "Missing library name"
|
|
assert version, "Missing library version"
|
|
|
|
return IDFComponent(name, version, source)
|
|
|
|
|
|
def _split_list_by_condition(
|
|
items: list[str], match_fn: Callable[[str], str | None]
|
|
) -> tuple[list[str], list[str]]:
|
|
"""
|
|
Splits a list into two lists based on a matching function.
|
|
|
|
Args:
|
|
items: List of items to split.
|
|
match_fn: Function that returns a value for items that should go into the "matched" list.
|
|
|
|
Returns:
|
|
A tuple (matched, non_matched)
|
|
"""
|
|
matched = []
|
|
non_matched = []
|
|
for item in items:
|
|
result = match_fn(item)
|
|
if result:
|
|
matched.append(result)
|
|
else:
|
|
non_matched.append(item)
|
|
return matched, non_matched
|
|
|
|
|
|
def generate_cmakelists_txt(component: IDFComponent) -> str:
|
|
"""
|
|
Generate a CMakeLists.txt file for an ESP-IDF component.
|
|
|
|
This function creates the necessary CMake configuration to build a library
|
|
with ESP-IDF, including source files, include directories, dependencies,
|
|
and build flags.
|
|
|
|
Args:
|
|
component: The IDFComponent object containing library metadata and path
|
|
|
|
Returns:
|
|
str: The complete CMakeLists.txt content as a string
|
|
"""
|
|
|
|
def escape_entry(p: PathType) -> str:
|
|
# In CMakeLists.txt, backslashes need to be escaped
|
|
return f'"{str(p)}"'.replace("\\", "\\\\")
|
|
|
|
# Extract the values
|
|
build_src_dir = component.data.get("build", {}).get("srcDir", None)
|
|
if not build_src_dir:
|
|
for d in ["src", "Src", "."]:
|
|
if (component.path / Path(d)).is_dir():
|
|
build_src_dir = d
|
|
break
|
|
|
|
build_include_dir = component.data.get("build", {}).get(
|
|
"includeDir", DEFAULT_BUILD_INCLUDE_DIR
|
|
)
|
|
build_src_filter = _ensure_list(
|
|
component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER)
|
|
)
|
|
build_flags = _ensure_list(
|
|
component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS)
|
|
)
|
|
|
|
# List all sources files
|
|
build_src_files = _collect_filtered_files(
|
|
component.path / Path(build_src_dir), build_src_filter
|
|
)
|
|
|
|
# Only bake library.json-declared deps here. Project-managed and
|
|
# built-in components come in via ${ESPHOME_PROJECT_MANAGED_COMPONENTS}
|
|
# / ${ESPHOME_PROJECT_BUILTIN_COMPONENTS} set in the top-level
|
|
# CMakeLists, so this file stays project-agnostic when shared from
|
|
# the pio_components cache.
|
|
requires: set[str] = {
|
|
dependency.get_require_name() for dependency in component.dependencies
|
|
}
|
|
|
|
# Only keep sources
|
|
build_src_files = [os.path.relpath(p, component.path) for p in build_src_files]
|
|
build_src_files = [
|
|
f for f in build_src_files if os.path.splitext(f)[1] in SRC_FILE_EXTENSIONS
|
|
]
|
|
|
|
# Handle build flags
|
|
include_dir_flags, build_flags = _split_list_by_condition(
|
|
build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None
|
|
)
|
|
link_directories, build_flags = _split_list_by_condition(
|
|
build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None
|
|
)
|
|
link_libraries, build_flags = _split_list_by_condition(
|
|
build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None
|
|
)
|
|
|
|
# Split include directories from build_flags
|
|
# Only keep an include directory if it exists
|
|
build_include_dirs = [build_include_dir, build_src_dir] + include_dir_flags
|
|
build_include_dirs = [
|
|
d for d in build_include_dirs if (component.path / Path(d)).is_dir()
|
|
]
|
|
|
|
# Split build_flags list into private and public lists
|
|
private_build_flags, public_build_flags = _split_list_by_condition(
|
|
build_flags, lambda a: a if a.startswith("-W") else None
|
|
)
|
|
|
|
# Generate the component
|
|
content = "idf_component_register(\n"
|
|
if build_src_files:
|
|
str_srcs = " ".join([escape_entry(p) for p in sorted(build_src_files)])
|
|
content += f" SRCS {str_srcs}\n"
|
|
if build_include_dirs:
|
|
str_include_dirs = " ".join([escape_entry(p) for p in build_include_dirs])
|
|
content += f" INCLUDE_DIRS {str_include_dirs}\n"
|
|
# Project-managed and built-in component lists are set per-project
|
|
# via idf_build_set_property in the top-level CMakeLists; expanded
|
|
# here at configure time. Keeping them out of the per-lib REQUIRES
|
|
# means this CMakeLists is project-agnostic and reusable from the
|
|
# pio_components cache across builds.
|
|
str_requires = " ".join(
|
|
[
|
|
*sorted(requires),
|
|
"${ESPHOME_PROJECT_MANAGED_COMPONENTS}",
|
|
"${ESPHOME_PROJECT_BUILTIN_COMPONENTS}",
|
|
]
|
|
)
|
|
content += f" REQUIRES {str_requires}\n"
|
|
content += ")\n"
|
|
|
|
# Add public and private build flags
|
|
if public_build_flags:
|
|
content += "target_compile_options(${COMPONENT_LIB} PUBLIC\n"
|
|
for build_flag in public_build_flags:
|
|
str_build_flag = escape_entry(build_flag)
|
|
content += f" {str_build_flag}\n"
|
|
content += ")\n"
|
|
if private_build_flags:
|
|
content += "target_compile_options(${COMPONENT_LIB} PRIVATE\n"
|
|
for build_flag in private_build_flags:
|
|
str_build_flag = escape_entry(build_flag)
|
|
content += f" {str_build_flag}\n"
|
|
content += ")\n"
|
|
|
|
# Add library paths and files
|
|
if link_directories:
|
|
content += "target_link_directories(${COMPONENT_LIB} INTERFACE\n"
|
|
for link_directory in link_directories:
|
|
str_build_flag = escape_entry(link_directory)
|
|
content += f" {str_build_flag}\n"
|
|
content += ")\n"
|
|
|
|
if link_libraries:
|
|
content += "target_link_libraries(${COMPONENT_LIB} INTERFACE\n"
|
|
for link_library in link_libraries:
|
|
str_build_flag = escape_entry(link_library)
|
|
content += f" {str_build_flag}\n"
|
|
content += ")\n"
|
|
|
|
# Add custom CMake scripts
|
|
content += "\n".join(
|
|
component.data.get(ESPHOME_DATA_KEY, {}).get(ESPHOME_DATA_EXTRA_CMAKE_KEY, [])
|
|
)
|
|
|
|
return content
|
|
|
|
|
|
def generate_idf_component_yml(component: IDFComponent) -> str:
|
|
"""
|
|
Generate ESP-IDF component YAML configuration for a library.
|
|
|
|
Args:
|
|
component: IDFComponent object to generate YAML for
|
|
|
|
Returns:
|
|
YAML string representation of ESP-IDF component configuration
|
|
"""
|
|
data = {}
|
|
|
|
description = component.data.get("description")
|
|
if description:
|
|
data["description"] = description
|
|
|
|
repository = component.data.get("repository", {}).get("url", None)
|
|
if repository:
|
|
data["repository"] = repository
|
|
|
|
for dependency in component.dependencies:
|
|
# Initialize dependencies section if needed
|
|
if "dependencies" not in data:
|
|
data["dependencies"] = {}
|
|
|
|
# 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)
|
|
|
|
|
|
def _check_library_data(data: dict):
|
|
"""
|
|
Check if a library data is compatible with the ESP-IDF framework.
|
|
|
|
A platform mismatch (e.g. an AVR-only library on ESP32) raises
|
|
``InvalidIDFComponent`` so the caller skips the library. A framework
|
|
mismatch only logs a warning — PIO manifests often understate the
|
|
frameworks they actually compile under, and IDF (unlike PIO's
|
|
``lib_compat_mode``) has no opt-out, so we include the library anyway.
|
|
|
|
Args:
|
|
data: PIO library manifest dict being processed.
|
|
|
|
Raises:
|
|
InvalidIDFComponent: If the library does not support the ESP32 platform.
|
|
"""
|
|
platforms = data.get("platforms", "*")
|
|
if isinstance(platforms, str):
|
|
platforms = [a.strip() for a in platforms.split(",")]
|
|
platforms = _ensure_list(platforms)
|
|
|
|
# Check if library supports ESP-IDF platform
|
|
valid_platforms = "*" in platforms or ESP32_PLATFORM in platforms
|
|
|
|
if not valid_platforms:
|
|
raise InvalidIDFComponent(f"Unsupported library platforms: {platforms}")
|
|
|
|
frameworks = data.get("frameworks", "*")
|
|
if isinstance(frameworks, str):
|
|
frameworks = [a.strip() for a in frameworks.split(",")]
|
|
frameworks = _ensure_list(frameworks)
|
|
|
|
# Check if library declares the active framework. PIO library manifests
|
|
# often list only "arduino" even when the library actually compiles fine
|
|
# under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to
|
|
# opt out of the check. Warn instead of failing so the user isn't forced to
|
|
# fork the library to fix the manifest.
|
|
framework = "arduino" if CORE.using_arduino else "espidf"
|
|
valid_framework = "*" in frameworks or framework in frameworks
|
|
|
|
if not valid_framework:
|
|
_LOGGER.warning(
|
|
"Library %s declares frameworks %s that do not include '%s'; including anyway",
|
|
data.get("name", "<unknown>"),
|
|
frameworks,
|
|
framework,
|
|
)
|
|
|
|
|
|
def _process_dependencies(component: IDFComponent):
|
|
"""
|
|
Process library dependencies and generate ESP-IDF components.
|
|
|
|
Args:
|
|
component: IDFComponent object being processed
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
name, version = component.name, component.version
|
|
dependencies = component.data.get("dependencies")
|
|
if not dependencies:
|
|
return
|
|
|
|
# PIO's library.json accepts both the list-of-dicts form and the
|
|
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
|
|
# the dict form so the loop below sees a uniform list. Iterating a
|
|
# dict gives string keys, which would silently fail the
|
|
# ``"name" in dependency`` substring check and skip every entry.
|
|
if isinstance(dependencies, dict):
|
|
normalized = []
|
|
for raw_name, spec in dependencies.items():
|
|
if "/" in raw_name:
|
|
owner, pkgname = raw_name.split("/", 1)
|
|
else:
|
|
owner, pkgname = None, raw_name
|
|
entry = {"name": pkgname, "owner": owner}
|
|
if isinstance(spec, dict):
|
|
entry.update(spec)
|
|
else:
|
|
entry["version"] = spec
|
|
normalized.append(entry)
|
|
dependencies = normalized
|
|
|
|
_LOGGER.info("Processing %s@%s component dependencies...", name, version)
|
|
for dependency in dependencies:
|
|
# Validate dependency structure
|
|
if not all(k in dependency for k in ("name", "version")):
|
|
_LOGGER.debug("Ignore invalid library: %s", dependency)
|
|
continue
|
|
|
|
try:
|
|
_check_library_data(dependency)
|
|
except InvalidIDFComponent as e:
|
|
_LOGGER.debug(
|
|
"Skip %s@%s: %s", dependency["name"], dependency["version"], str(e)
|
|
)
|
|
continue
|
|
|
|
# The version field may actually contain a URL
|
|
version = dependency["version"]
|
|
url = None
|
|
try:
|
|
result = urlparse(version)
|
|
if all([result.scheme, result.netloc]):
|
|
url, version = version, None
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
# Generate ESP-IDF component from PlatformIO library
|
|
component.dependencies.append(
|
|
_generate_idf_component(
|
|
Library(
|
|
_owner_pkgname_to_name(
|
|
dependency.get("owner", None), dependency.get("name")
|
|
),
|
|
version,
|
|
url,
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def _parse_library_json(library_json_path: PathType):
|
|
"""
|
|
Load and parse a JSON file describing a library.
|
|
|
|
Args:
|
|
library_json_path (PathType): Path to the JSON file.
|
|
|
|
Returns:
|
|
dict: Parsed JSON content as a Python dictionary.
|
|
"""
|
|
with open(library_json_path, encoding="utf8") as fp:
|
|
return json.load(fp)
|
|
|
|
|
|
def _parse_library_properties(library_properties_path: PathType):
|
|
"""
|
|
Parse a key-value platformio .properties style file into a dictionary.
|
|
|
|
Args:
|
|
library_properties_path (PathType): Path to the properties file.
|
|
|
|
Returns:
|
|
dict[str, str]: Mapping of parsed property keys to values.
|
|
"""
|
|
with open(library_properties_path, encoding="utf8") as fp:
|
|
data = {}
|
|
for line in fp.read().splitlines():
|
|
line = line.strip()
|
|
if not line or "=" not in line:
|
|
continue
|
|
# skip comments
|
|
if line.startswith("#"):
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
if not value.strip():
|
|
continue
|
|
data[key.strip()] = value.strip()
|
|
return data
|
|
|
|
|
|
def _generate_idf_component(library: Library, force: bool = False) -> IDFComponent:
|
|
"""
|
|
Generate an ESP-IDF component from a library specification.
|
|
|
|
This function resolves the library, downloads it, processes metadata files,
|
|
and generates necessary ESP-IDF build files (CMakeLists.txt, idf_component.yml).
|
|
|
|
Args:
|
|
library: The library specification containing name, version, and repository URL
|
|
force: If True, forces re-download of the library even if it exists locally
|
|
|
|
Returns:
|
|
IDFComponent: The generated component object with resolved metadata
|
|
"""
|
|
_LOGGER.info("Generate IDF component for %s library ...", library)
|
|
|
|
# Resolve component name, version and url
|
|
component = _convert_library_to_component(library)
|
|
name, version = component.name, component.version
|
|
|
|
# Download the library
|
|
component.download(force)
|
|
|
|
# Paths to component metadata and build files
|
|
library_json_path = component.path / "library.json"
|
|
library_properties_path = component.path / "library.properties"
|
|
cmakelists_txt_path = component.path / "CMakeLists.txt"
|
|
idf_component_yml_path = component.path / "idf_component.yml"
|
|
|
|
# Bundled CMakeLists.txt / idf_component.yml are ignored -- library
|
|
# authors' IDF support is frequently broken (bogus REQUIRES, hard-coded
|
|
# arduino-esp32, etc.). We always regenerate.
|
|
|
|
if library_json_path.is_file():
|
|
component.data = _parse_library_json(library_json_path)
|
|
elif library_properties_path.is_file():
|
|
component.data = _parse_library_properties(library_properties_path)
|
|
else:
|
|
raise RuntimeError(
|
|
"Invalid PIO library: missing library.json and/or library.properties"
|
|
)
|
|
|
|
# Check if the component is usable with ESP-IDF before executing any
|
|
# third-party Python from the library (``_apply_extra_script`` below).
|
|
_check_library_data(component.data)
|
|
|
|
# If the library declares a PIO ``extraScript``, run it against a
|
|
# fake SCons env so we can fold its captured LIBPATH/LIBS/etc into
|
|
# the build-flag pipeline ``generate_cmakelists_txt`` consumes
|
|
# below. Without this, libraries that wire per-MCU archive linking
|
|
# via extraScript fail to link under native ESP-IDF.
|
|
_apply_extra_script(component)
|
|
|
|
# Handle the dependencies (convert PlatformIO library to ESP-IDF component if needed)
|
|
_process_dependencies(component)
|
|
|
|
_LOGGER.debug("Generating CMakeLists.txt for %s@%s ...", name, version)
|
|
write_file_if_changed(
|
|
cmakelists_txt_path,
|
|
generate_cmakelists_txt(component),
|
|
)
|
|
|
|
_LOGGER.debug("Generating idf_component.yml for %s@%s ...", name, version)
|
|
write_file_if_changed(
|
|
idf_component_yml_path,
|
|
generate_idf_component_yml(component),
|
|
)
|
|
|
|
return component
|
|
|
|
|
|
def generate_idf_component(
|
|
library: Library, force: bool = False
|
|
) -> tuple[str, str, Path]:
|
|
"""
|
|
Generate an ESP-IDF component and return its name, version, and path.
|
|
|
|
This is a wrapper function that calls _generate_idf_component and returns
|
|
the standardized tuple format (name, version, path).
|
|
|
|
Args:
|
|
library: The library specification containing name, version, and repository URL
|
|
force: If True, forces re-download of the library even if it exists locally
|
|
|
|
Returns:
|
|
tuple[str, str, Path]: A tuple containing (component_name, component_version, component_path)
|
|
"""
|
|
component = _generate_idf_component(library, force)
|
|
return component.get_sanitized_name(), component.version, component.path
|