diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba6db99b84..9e8a040888 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true diff --git a/CODEOWNERS b/CODEOWNERS index c466204b66..5b1ae65f1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -148,6 +148,7 @@ esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull esphome/components/emmeti/* @E440QF +esphome/components/emontx/* @FredM67 @glynhudson @TrystanLea esphome/components/ens160/* @latonita esphome/components/ens160_base/* @latonita @vincentscode esphome/components/ens160_i2c/* @latonita diff --git a/esphome/__main__.py b/esphome/__main__.py index 87abd7f796..a696cceffb 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1242,6 +1242,38 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 +def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: + from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator + + creator = ConfigBundleCreator(config) + + if args.list_only: + files = creator.discover_files() + for bf in sorted(files, key=lambda f: f.path): + safe_print(f" {bf.path}") + _LOGGER.info("Found %d files", len(files)) + return 0 + + result = creator.create_bundle() + + if args.output: + output_path = Path(args.output) + else: + stem = CORE.config_path.stem + output_path = CORE.config_dir / f"{stem}{BUNDLE_EXTENSION}" + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(result.data) + + _LOGGER.info( + "Bundle created: %s (%d files, %.1f KB)", + output_path, + len(result.files), + len(result.data) / 1024, + ) + return 0 + + def command_dashboard(args: ArgsProtocol) -> int | None: from esphome.dashboard import dashboard @@ -1517,6 +1549,7 @@ POST_CONFIG_ACTIONS = { "rename": command_rename, "discover": command_discover, "analyze-memory": command_analyze_memory, + "bundle": command_bundle, } SIMPLE_CONFIG_ACTIONS = [ @@ -1818,6 +1851,24 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) + parser_bundle = subparsers.add_parser( + "bundle", + help="Create a self-contained config bundle for remote compilation.", + ) + parser_bundle.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + parser_bundle.add_argument( + "-o", + "--output", + help="Output path for the bundle archive.", + ) + parser_bundle.add_argument( + "--list-only", + help="List discovered files without creating the archive.", + action="store_true", + ) + # Keep backward compatibility with the old command line format of # esphome . # @@ -1896,6 +1947,16 @@ def run_esphome(argv): _LOGGER.warning("Skipping secrets file %s", conf_path) return 0 + # Bundle support: if the configuration is a .esphomebundle, extract it + # and rewrite conf_path to the extracted YAML config. + from esphome.bundle import is_bundle_path, prepare_bundle_for_compile + + if is_bundle_path(conf_path): + _LOGGER.info("Extracting config bundle %s...", conf_path) + conf_path = prepare_bundle_for_compile(conf_path) + # Update the argument so downstream code sees the extracted path + args.configuration[0] = str(conf_path) + CORE.config_path = conf_path CORE.dashboard = args.dashboard diff --git a/esphome/automation.py b/esphome/automation.py index 94d64086ec..b4dcc41995 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import logging import esphome.codegen as cg @@ -715,3 +716,35 @@ async def build_callback_automation( # MockObjs (not user input), and there's no Expression type for positional # aggregate initialization (StructInitializer uses named fields). cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) + + +@dataclass(frozen=True, slots=True) +class CallbackAutomation: + """A single callback automation entry for build_callback_automations.""" + + conf_key: str + callback_method: str + args: TemplateArgsType = field(default_factory=list) + forwarder: MockObj | MockObjClass | None = None + + +async def build_callback_automations( + parent: MockObj, + config: ConfigType, + entries: tuple[CallbackAutomation, ...], +) -> None: + """Build multiple callback automations from a tuple of entries. + + :param parent: The component object (e.g., button, sensor). + :param config: The full component config dict. + :param entries: Tuple of CallbackAutomation entries to process. + """ + for entry in entries: + for conf in config.get(entry.conf_key, []): + await build_callback_automation( + parent, + entry.callback_method, + entry.args, + conf, + forwarder=entry.forwarder, + ) diff --git a/esphome/bundle.py b/esphome/bundle.py new file mode 100644 index 0000000000..b6816c7c95 --- /dev/null +++ b/esphome/bundle.py @@ -0,0 +1,699 @@ +"""Config bundle creator and extractor for ESPHome. + +A bundle is a self-contained .tar.gz archive containing a YAML config +and every local file it depends on. Bundles can be created from a config +and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +import io +import json +import logging +from pathlib import Path +import re +import shutil +import tarfile +from typing import Any + +from esphome import const, yaml_util +from esphome.const import ( + CONF_ESPHOME, + CONF_EXTERNAL_COMPONENTS, + CONF_INCLUDES, + CONF_INCLUDES_C, + CONF_PATH, + CONF_SOURCE, + CONF_TYPE, +) +from esphome.core import CORE, EsphomeError + +_LOGGER = logging.getLogger(__name__) + +BUNDLE_EXTENSION = ".esphomebundle.tar.gz" +MANIFEST_FILENAME = "manifest.json" +CURRENT_MANIFEST_VERSION = 1 +MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB +MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB + +# Directories preserved across bundle extractions (build caches) +_PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio") +_BUNDLE_STAGING_DIR = ".bundle_staging" + + +class ManifestKey(StrEnum): + """Keys used in bundle manifest.json.""" + + MANIFEST_VERSION = "manifest_version" + ESPHOME_VERSION = "esphome_version" + CONFIG_FILENAME = "config_filename" + FILES = "files" + HAS_SECRETS = "has_secrets" + + +# String prefixes that are never local file paths +_NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<") + +# File extensions recognized when resolving relative path strings. +# A relative string with one of these extensions is resolved against the +# config directory and included if the file exists. +_KNOWN_FILE_EXTENSIONS = frozenset( + { + # Fonts + ".ttf", + ".otf", + ".woff", + ".woff2", + ".pcf", + ".bdf", + # Images + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".svg", + ".ico", + ".webp", + # Certificates + ".pem", + ".crt", + ".key", + ".der", + ".p12", + ".pfx", + # C/C++ includes + ".h", + ".hpp", + ".c", + ".cpp", + ".ino", + # Web assets + ".css", + ".js", + ".html", + } +) + + +# Matches !secret references in YAML text. This is intentionally a simple +# regex scan rather than a YAML parse — it may match inside comments or +# multi-line strings, which is the conservative direction (include more +# secrets rather than fewer). +_SECRET_RE = re.compile(r"!secret\s+(\S+)") + + +def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: + """Scan YAML files for ``!secret `` references.""" + keys: set[str] = set() + for fpath in yaml_files: + try: + text = fpath.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for match in _SECRET_RE.finditer(text): + keys.add(match.group(1)) + return keys + + +@dataclass +class BundleFile: + """A file to include in the bundle.""" + + path: str # Relative path inside the archive + source: Path # Absolute path on disk + + +@dataclass +class BundleResult: + """Result of creating a bundle.""" + + data: bytes + manifest: dict[str, Any] + files: list[BundleFile] + + +@dataclass +class BundleManifest: + """Parsed and validated bundle manifest.""" + + manifest_version: int + esphome_version: str + config_filename: str + files: list[str] + has_secrets: bool + + +class ConfigBundleCreator: + """Creates a self-contained bundle from an ESPHome config.""" + + def __init__(self, config: dict[str, Any]) -> None: + self._config = config + self._config_dir = CORE.config_dir + self._config_path = CORE.config_path + self._files: list[BundleFile] = [] + self._seen_paths: set[Path] = set() + self._secrets_paths: set[Path] = set() + + def discover_files(self) -> list[BundleFile]: + """Discover all files needed for the bundle.""" + self._files = [] + self._seen_paths = set() + self._secrets_paths = set() + + # The main config file + self._add_file(self._config_path) + + # Phase 1: YAML includes (tracked during config loading) + self._discover_yaml_includes() + + # Phase 2: Component-referenced files from validated config + self._discover_component_files() + + return list(self._files) + + def create_bundle(self) -> BundleResult: + """Create the bundle archive.""" + files = self.discover_files() + + # Determine which secret keys are actually referenced by the + # bundled YAML files so we only ship those, not the entire + # secrets.yaml which may contain secrets for other devices. + yaml_sources = [ + bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") + ] + used_secret_keys = _find_used_secret_keys(yaml_sources) + filtered_secrets = self._build_filtered_secrets(used_secret_keys) + + has_secrets = bool(filtered_secrets) + if has_secrets: + _LOGGER.warning( + "Bundle contains secrets (e.g. Wi-Fi passwords). " + "Do not share it with untrusted parties." + ) + + manifest = self._build_manifest(files, has_secrets=has_secrets) + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + # Add manifest first + manifest_data = json.dumps(manifest, indent=2).encode("utf-8") + _add_bytes_to_tar(tar, MANIFEST_FILENAME, manifest_data) + + # Add filtered secrets files + for rel_path, data in sorted(filtered_secrets.items()): + _add_bytes_to_tar(tar, rel_path, data) + + # Add files in sorted order for determinism, skipping secrets + # files which were already added above with filtered content + for bf in sorted(files, key=lambda f: f.path): + if bf.source in self._secrets_paths: + continue + self._add_to_tar(tar, bf) + + return BundleResult(data=buf.getvalue(), manifest=manifest, files=files) + + def _add_file(self, abs_path: Path) -> bool: + """Add a file to the bundle. Returns False if already added.""" + abs_path = abs_path.resolve() + if abs_path in self._seen_paths: + return False + if not abs_path.is_file(): + _LOGGER.warning("Bundle: skipping missing file %s", abs_path) + return False + + rel_path = self._relative_to_config_dir(abs_path) + if rel_path is None: + _LOGGER.warning( + "Bundle: skipping file outside config directory: %s", abs_path + ) + return False + + self._seen_paths.add(abs_path) + self._files.append(BundleFile(path=rel_path, source=abs_path)) + return True + + def _add_directory(self, abs_path: Path) -> None: + """Recursively add all files in a directory.""" + abs_path = abs_path.resolve() + if not abs_path.is_dir(): + _LOGGER.warning("Bundle: skipping missing directory %s", abs_path) + return + for child in sorted(abs_path.rglob("*")): + if child.is_file() and "__pycache__" not in child.parts: + self._add_file(child) + + def _relative_to_config_dir(self, abs_path: Path) -> str | None: + """Get a path relative to the config directory. Returns None if outside. + + Always uses forward slashes for consistency in tar archives. + """ + try: + return abs_path.relative_to(self._config_dir).as_posix() + except ValueError: + return None + + def _discover_yaml_includes(self) -> None: + """Discover YAML files loaded during config parsing. + + We track files by wrapping _load_yaml_internal. The config has already + been loaded at this point (bundle is a POST_CONFIG_ACTION), so we + re-load just to discover the file list. + + Secrets files are tracked separately so we can filter them to + only include the keys this config actually references. + """ + with yaml_util.track_yaml_loads() as loaded_files: + try: + yaml_util.load_yaml(self._config_path) + except EsphomeError: + _LOGGER.debug( + "Bundle: re-loading YAML for include discovery failed, " + "proceeding with partial file list" + ) + + for fpath in loaded_files: + if fpath == self._config_path.resolve(): + continue # Already added as config + if fpath.name in const.SECRETS_FILES: + self._secrets_paths.add(fpath) + self._add_file(fpath) + + def _discover_component_files(self) -> None: + """Walk the validated config for file references. + + Uses a generic recursive walk to find file paths instead of + hardcoding per-component knowledge about config dict formats. + After validation, components typically resolve paths to absolute + using CORE.relative_config_path() or cv.file_(). Relative paths + with known file extensions are also resolved and checked. + + Core ESPHome concepts that use relative paths or directories + are handled explicitly. + """ + config = self._config + + # Generic walk: find all file paths in the validated config + self._walk_config_for_files(config) + + # --- Core ESPHome concepts needing explicit handling --- + + # esphome.includes / includes_c - can be relative paths and directories + esphome_conf = config.get(CONF_ESPHOME, {}) + for include_path in esphome_conf.get(CONF_INCLUDES, []): + resolved = _resolve_include_path(include_path) + if resolved is None: + continue + if resolved.is_dir(): + self._add_directory(resolved) + else: + self._add_file(resolved) + for include_path in esphome_conf.get(CONF_INCLUDES_C, []): + resolved = _resolve_include_path(include_path) + if resolved is not None: + self._add_file(resolved) + + # external_components with source: local - directories + for ext_conf in config.get(CONF_EXTERNAL_COMPONENTS, []): + source = ext_conf.get(CONF_SOURCE, {}) + if not isinstance(source, dict): + continue + if source.get(CONF_TYPE) != "local": + continue + path = source.get(CONF_PATH) + if not path: + continue + p = Path(path) + if not p.is_absolute(): + p = CORE.relative_config_path(p) + self._add_directory(p) + + def _walk_config_for_files(self, obj: Any) -> None: + """Recursively walk the config dict looking for file path references.""" + if isinstance(obj, dict): + for value in obj.values(): + self._walk_config_for_files(value) + elif isinstance(obj, (list, tuple)): + for item in obj: + self._walk_config_for_files(item) + elif isinstance(obj, Path): + if obj.is_absolute() and obj.is_file(): + self._add_file(obj) + elif isinstance(obj, str): + self._check_string_path(obj) + + def _check_string_path(self, value: str) -> None: + """Check if a string value is a local file reference.""" + # Fast exits for strings that cannot be file paths + if len(value) < 2 or "\n" in value: + return + if value.startswith(_NON_PATH_PREFIXES): + return + # File paths must contain a path separator or a dot (for extension) + if "/" not in value and "\\" not in value and "." not in value: + return + + p = Path(value) + + # Absolute path - check if it points to an existing file + if p.is_absolute(): + if p.is_file(): + self._add_file(p) + return + + # Relative path with a known file extension - likely a component + # validator that forgot to resolve to absolute via cv.file_() or + # CORE.relative_config_path(). Warn and try to resolve. + if p.suffix.lower() in _KNOWN_FILE_EXTENSIONS: + _LOGGER.warning( + "Bundle: non-absolute path in validated config: %s " + "(component validator should return absolute paths)", + value, + ) + resolved = CORE.relative_config_path(p) + if resolved.is_file(): + self._add_file(resolved) + + def _build_filtered_secrets(self, used_keys: set[str]) -> dict[str, bytes]: + """Build filtered secrets files containing only the referenced keys. + + Returns a dict mapping relative archive path to YAML bytes. + """ + if not used_keys or not self._secrets_paths: + return {} + + result: dict[str, bytes] = {} + for secrets_path in self._secrets_paths: + rel_path = self._relative_to_config_dir(secrets_path) + if rel_path is None: + continue + try: + all_secrets = yaml_util.load_yaml(secrets_path, clear_secrets=False) + except EsphomeError: + _LOGGER.warning("Bundle: failed to load secrets file %s", secrets_path) + continue + if not isinstance(all_secrets, dict): + continue + filtered = {k: v for k, v in all_secrets.items() if k in used_keys} + if filtered: + data = yaml_util.dump(filtered, show_secrets=True).encode("utf-8") + result[rel_path] = data + return result + + def _build_manifest( + self, files: list[BundleFile], *, has_secrets: bool + ) -> dict[str, Any]: + """Build the manifest.json content.""" + return { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.ESPHOME_VERSION: const.__version__, + ManifestKey.CONFIG_FILENAME: self._config_path.name, + ManifestKey.FILES: [f.path for f in files], + ManifestKey.HAS_SECRETS: has_secrets, + } + + @staticmethod + def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: + """Add a BundleFile to the tar archive with deterministic metadata.""" + with open(bf.source, "rb") as f: + _add_bytes_to_tar(tar, bf.path, f.read()) + + +def extract_bundle( + bundle_path: Path, + target_dir: Path | None = None, +) -> Path: + """Extract a bundle archive and return the path to the config YAML. + + Sanity checks reject path traversal, symlinks, absolute paths, and + oversized archives to prevent accidental file overwrites or extraction + outside the target directory. These are **not** a security boundary — + bundles are assumed to come from the user's own machine or a trusted + build pipeline. + + Args: + bundle_path: Path to the .tar.gz bundle file. + target_dir: Directory to extract into. If None, extracts next to + the bundle file in a directory named after it. + + Returns: + Absolute path to the extracted config YAML file. + + Raises: + EsphomeError: If the bundle is invalid or extraction fails. + """ + + bundle_path = bundle_path.resolve() + if not bundle_path.is_file(): + raise EsphomeError(f"Bundle file not found: {bundle_path}") + + if target_dir is None: + target_dir = _default_target_dir(bundle_path) + + target_dir = target_dir.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + # Read and validate the archive + try: + with tarfile.open(bundle_path, "r:gz") as tar: + manifest = _read_manifest_from_tar(tar) + _validate_tar_members(tar, target_dir) + tar.extractall(path=target_dir, filter="data") + except tarfile.TarError as err: + raise EsphomeError(f"Failed to extract bundle: {err}") from err + + config_filename = manifest[ManifestKey.CONFIG_FILENAME] + config_path = target_dir / config_filename + if not config_path.is_file(): + raise EsphomeError( + f"Bundle manifest references config '{config_filename}' " + f"but it was not found in the archive" + ) + + return config_path + + +def read_bundle_manifest(bundle_path: Path) -> BundleManifest: + """Read and validate the manifest from a bundle without full extraction. + + Args: + bundle_path: Path to the .tar.gz bundle file. + + Returns: + Parsed BundleManifest. + + Raises: + EsphomeError: If the manifest is missing, invalid, or version unsupported. + """ + + try: + with tarfile.open(bundle_path, "r:gz") as tar: + manifest = _read_manifest_from_tar(tar) + except tarfile.TarError as err: + raise EsphomeError(f"Failed to read bundle: {err}") from err + + return BundleManifest( + manifest_version=manifest[ManifestKey.MANIFEST_VERSION], + esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), + config_filename=manifest[ManifestKey.CONFIG_FILENAME], + files=manifest.get(ManifestKey.FILES, []), + has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), + ) + + +def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict[str, Any]: + """Read and validate manifest.json from an open tar archive.""" + + try: + member = tar.getmember(MANIFEST_FILENAME) + except KeyError: + raise EsphomeError("Invalid bundle: missing manifest.json") from None + + f = tar.extractfile(member) + if f is None: + raise EsphomeError("Invalid bundle: manifest.json is not a regular file") + + if member.size > MAX_MANIFEST_SIZE: + raise EsphomeError( + f"Invalid bundle: manifest.json too large " + f"({member.size} bytes, max {MAX_MANIFEST_SIZE})" + ) + + try: + manifest = json.loads(f.read()) + except (json.JSONDecodeError, UnicodeDecodeError) as err: + raise EsphomeError(f"Invalid bundle: malformed manifest.json: {err}") from err + + # Version check + version = manifest.get(ManifestKey.MANIFEST_VERSION) + if version is None: + raise EsphomeError("Invalid bundle: manifest.json missing 'manifest_version'") + if not isinstance(version, int) or version < 1: + raise EsphomeError( + f"Invalid bundle: manifest_version must be a positive integer, got {version!r}" + ) + if version > CURRENT_MANIFEST_VERSION: + raise EsphomeError( + f"Bundle manifest version {version} is newer than this ESPHome " + f"version supports (max {CURRENT_MANIFEST_VERSION}). " + f"Please upgrade ESPHome to compile this bundle." + ) + + # Required fields + if ManifestKey.CONFIG_FILENAME not in manifest: + raise EsphomeError("Invalid bundle: manifest.json missing 'config_filename'") + + return manifest + + +def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None: + """Sanity-check tar members to prevent mistakes and accidental overwrites. + + This is not a security boundary — bundles are created locally or come + from a trusted build pipeline. The checks catch malformed archives + and common mistakes (stray absolute paths, ``..`` components) that + could silently overwrite unrelated files. + """ + + total_size = 0 + for member in tar.getmembers(): + # Reject absolute paths (Unix and Windows) + if member.name.startswith(("/", "\\")): + raise EsphomeError( + f"Invalid bundle: absolute path in archive: {member.name}" + ) + + # Reject path traversal (split on both / and \ for cross-platform) + parts = re.split(r"[/\\]", member.name) + if ".." in parts: + raise EsphomeError( + f"Invalid bundle: path traversal in archive: {member.name}" + ) + + # Reject symlinks + if member.issym() or member.islnk(): + raise EsphomeError(f"Invalid bundle: symlink in archive: {member.name}") + + # Ensure extraction stays within target_dir + target_path = (target_dir / member.name).resolve() + if not target_path.is_relative_to(target_dir): + raise EsphomeError( + f"Invalid bundle: file would extract outside target: {member.name}" + ) + + # Track total decompressed size + total_size += member.size + if total_size > MAX_DECOMPRESSED_SIZE: + raise EsphomeError( + f"Invalid bundle: decompressed size exceeds " + f"{MAX_DECOMPRESSED_SIZE // (1024 * 1024)}MB limit" + ) + + +def is_bundle_path(path: Path) -> bool: + """Check if a path looks like a bundle file.""" + return path.name.lower().endswith(BUNDLE_EXTENSION) + + +def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: + """Add in-memory bytes to a tar archive with deterministic metadata.""" + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.mode = 0o644 + tar.addfile(info, io.BytesIO(data)) + + +def _resolve_include_path(include_path: Any) -> Path | None: + """Resolve an include path to absolute, skipping system includes.""" + if isinstance(include_path, str) and include_path.startswith("<"): + return None # System include, not a local file + p = Path(include_path) + if not p.is_absolute(): + p = CORE.relative_config_path(p) + return p + + +def _default_target_dir(bundle_path: Path) -> Path: + """Compute the default extraction directory for a bundle.""" + name = bundle_path.name + if name.lower().endswith(BUNDLE_EXTENSION): + name = name[: -len(BUNDLE_EXTENSION)] + return bundle_path.parent / name + + +def _restore_preserved_dirs(preserved: dict[str, Path], target_dir: Path) -> None: + """Move preserved build cache directories back into target_dir. + + If the bundle contained entries under a preserved directory name, + the extracted copy is removed so the original cache always wins. + """ + for dirname, src in preserved.items(): + dst = target_dir / dirname + if dst.exists(): + shutil.rmtree(dst) + shutil.move(str(src), str(dst)) + + +def prepare_bundle_for_compile( + bundle_path: Path, + target_dir: Path | None = None, +) -> Path: + """Extract a bundle for compilation, preserving build caches. + + Unlike extract_bundle(), this preserves .esphome/ and .pioenvs/ + directories in the target if they already exist (for incremental builds). + + Args: + bundle_path: Path to the .tar.gz bundle file. + target_dir: Directory to extract into. Must be specified for + build server use. + + Returns: + Absolute path to the extracted config YAML file. + """ + + bundle_path = bundle_path.resolve() + if not bundle_path.is_file(): + raise EsphomeError(f"Bundle file not found: {bundle_path}") + + if target_dir is None: + target_dir = _default_target_dir(bundle_path) + + target_dir = target_dir.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + preserved: dict[str, Path] = {} + + # Temporarily move preserved dirs out of the way + staging = target_dir / _BUNDLE_STAGING_DIR + for dirname in _PRESERVE_DIRS: + src = target_dir / dirname + if src.is_dir(): + dst = staging / dirname + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dst)) + preserved[dirname] = dst + + try: + # Clean non-preserved content and extract fresh + for item in target_dir.iterdir(): + if item.name == _BUNDLE_STAGING_DIR: + continue + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + + config_path = extract_bundle(bundle_path, target_dir) + finally: + # Restore preserved dirs (idempotent) and clean staging + _restore_preserved_dirs(preserved, target_dir) + if staging.is_dir(): + shutil.rmtree(staging) + + return config_path diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 4ee073a15b..9fcdf42ecb 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -111,42 +111,66 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder + ), + automation.CallbackAutomation( + CONF_ON_TRIGGERED, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_TRIGGERED + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMING, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING), + ), + automation.CallbackAutomation( + CONF_ON_PENDING, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_PENDING + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMED_HOME, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_ARMED_HOME + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMED_NIGHT, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_ARMED_NIGHT + ), + ), + automation.CallbackAutomation( + CONF_ON_ARMED_AWAY, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_ARMED_AWAY + ), + ), + automation.CallbackAutomation( + CONF_ON_DISARMED, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + AlarmControlPanelState.ACP_STATE_DISARMED + ), + ), + automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"), + automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"), + automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"), +) + + @setup_entity("alarm_control_panel") async def setup_alarm_control_panel_core_(var, config): - for conf in config.get(CONF_ON_STATE, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [], conf, forwarder=StateAnyForwarder - ) - _STATE_ENTER_MAP = { - CONF_ON_TRIGGERED: AlarmControlPanelState.ACP_STATE_TRIGGERED, - CONF_ON_ARMING: AlarmControlPanelState.ACP_STATE_ARMING, - CONF_ON_PENDING: AlarmControlPanelState.ACP_STATE_PENDING, - CONF_ON_ARMED_HOME: AlarmControlPanelState.ACP_STATE_ARMED_HOME, - CONF_ON_ARMED_NIGHT: AlarmControlPanelState.ACP_STATE_ARMED_NIGHT, - CONF_ON_ARMED_AWAY: AlarmControlPanelState.ACP_STATE_ARMED_AWAY, - CONF_ON_DISARMED: AlarmControlPanelState.ACP_STATE_DISARMED, - } - for conf_key, state_enum in _STATE_ENTER_MAP.items(): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, - "add_on_state_callback", - [], - conf, - forwarder=StateEnterForwarder.template(state_enum), - ) - for conf in config.get(CONF_ON_CLEARED, []): - await automation.build_callback_automation( - var, "add_on_cleared_callback", [], conf - ) - for conf in config.get(CONF_ON_CHIME, []): - await automation.build_callback_automation( - var, "add_on_chime_callback", [], conf - ) - for conf in config.get(CONF_ON_READY, []): - await automation.build_callback_automation( - var, "add_on_ready_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) if mqtt_id := config.get(CONF_MQTT_ID): diff --git a/esphome/components/bh1900nux/sensor.py b/esphome/components/bh1900nux/sensor.py index 5e1c0395af..a70db3555a 100644 --- a/esphome/components/bh1900nux/sensor.py +++ b/esphome/components/bh1900nux/sensor.py @@ -12,7 +12,7 @@ CODEOWNERS = ["@B48D81EFCC"] sensor_ns = cg.esphome_ns.namespace("bh1900nux") BH1900NUXSensor = sensor_ns.class_( - "BH1900NUXSensor", cg.PollingComponent, i2c.I2CDevice + "BH1900NUXSensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) CONFIG_SCHEMA = ( diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index d8cdaa5d58..0b36c299f6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -531,16 +531,31 @@ def binary_sensor_schema( return _BINARY_SENSOR_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PRESS, + "add_on_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_RELEASE, + "add_on_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(bool, "x")] + ), + automation.CallbackAutomation( + CONF_ON_STATE_CHANGE, + "add_full_state_callback", + [(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")], + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_binary_sensor_automations(var, config): - for conf_key, forwarder in ( - (CONF_ON_PRESS, automation.TriggerOnTrueForwarder), - (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [], conf, forwarder=forwarder - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( @@ -572,22 +587,6 @@ async def _build_binary_sensor_automations(var, config): await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_STATE, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [(bool, "x")], conf - ) - - for conf in config.get(CONF_ON_STATE_CHANGE, []): - await automation.build_callback_automation( - var, - "add_full_state_callback", - [ - (cg.optional.template(bool), "x_previous"), - (cg.optional.template(bool), "x"), - ], - conf, - ) - @setup_entity("binary_sensor") async def setup_binary_sensor_core_(var, config): diff --git a/esphome/components/bp1658cj/output.py b/esphome/components/bp1658cj/output.py index 023b6ecd1e..78cf717aba 100644 --- a/esphome/components/bp1658cj/output.py +++ b/esphome/components/bp1658cj/output.py @@ -14,7 +14,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_BP1658CJ_ID): cv.use_id(BP1658CJ), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index f279b6ffe3..2c19ea69b1 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -79,12 +79,14 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_PRESS, "add_on_press_callback"), +) + + @setup_entity("button") async def setup_button_core_(var, config): - for conf in config.get(CONF_ON_PRESS, []): - await automation.build_callback_automation( - var, "add_on_press_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) setup_device_class(config) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index adc1913791..7796f5d891 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -64,15 +64,19 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINISHED_PLAYBACK, "add_on_finished_playback_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): - await automation.build_callback_automation( - var, "add_on_finished_playback_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index dd7f2b9f56..9c493bfcff 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -37,7 +37,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, cv.Optional(CONF_THERMAL_MBUS_ID, default=3): cv.int_, - cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_, + cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_range(min=1), cv.Optional(CONF_REQUEST_PIN): pins.gpio_output_pin_schema, cv.Optional( CONF_REQUEST_INTERVAL, default="0ms" diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py new file mode 100644 index 0000000000..a2d4349698 --- /dev/null +++ b/esphome/components/emontx/__init__.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass, field + +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_COMMAND, + CONF_ID, + CONF_ON_DATA, + CONF_RX_BUFFER_SIZE, + CONF_UART_ID, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +AUTO_LOAD = ["json"] +CODEOWNERS = ["@FredM67", "@TrystanLea", "@glynhudson"] +DEPENDENCIES = ["uart"] + +emontx_ns = cg.esphome_ns.namespace("emontx") +EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) + +# Action to send command to emonTx +EmonTxSendCommandAction = emontx_ns.class_("EmonTxSendCommandAction", automation.Action) + +CONF_EMONTX_ID = "emontx_id" +CONF_TAG_NAME = "tag_name" +CONF_ON_JSON = "on_json" + +DOMAIN = "emontx" + +MINIMUM_RX_BUFFER_SIZE = 2048 + + +@dataclass +class EmonTxData: + sensor_counts: dict[str, int] = field(default_factory=dict) + + +def _get_data() -> EmonTxData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = EmonTxData() + return CORE.data[DOMAIN] + + +# Main configuration schema +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(EmonTx), + cv.Optional(CONF_ON_JSON): automation.validate_automation({}), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + + # Count sensors registered to this hub (IDs are resolved at final_validate stage) + hub_id = str(config[CONF_ID]) + sensor_count = sum( + 1 + for s in full_config.get("sensor", []) + if s.get("platform") == "emontx" and str(s.get(CONF_EMONTX_ID)) == hub_id + ) + _get_data().sensor_counts[hub_id] = sensor_count + + # Ensure UART RX buffer size is large enough to handle data bursts from firmware + for uart_conf in full_config["uart"]: + if uart_conf[CONF_ID] == config[CONF_UART_ID]: + current_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] + if current_buffer_size < MINIMUM_RX_BUFFER_SIZE: + raise cv.Invalid( + f"Component emontx requires UART '{config[CONF_UART_ID]}' to have " + f"rx_buffer_size of at least {MINIMUM_RX_BUFFER_SIZE} bytes " + f"(currently set to {current_buffer_size} bytes). " + f"Please add 'rx_buffer_size: {MINIMUM_RX_BUFFER_SIZE}' to your uart configuration.", + path=[CONF_UART_ID], + ) + break + + # Validate UART settings + schema = uart.final_validate_device_schema( + "emontx", + baud_rate=115200, + require_tx=False, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, + ) + return schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_JSON, + "add_on_json_callback", + [(cg.JsonObject, "json"), (cg.std_string, "raw_json")], + ), + automation.CallbackAutomation( + CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")] + ), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + # Initialize sensor storage with count from final_validate + sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0) + if sensor_count > 0: + cg.add(var.init_sensors(sensor_count)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +# Action: emontx.send_command + +EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(EmonTx), + cv.Required(CONF_COMMAND): cv.templatable(cv.string), + } +) + + +@automation.register_action( + "emontx.send_command", + EmonTxSendCommandAction, + EMONTX_SEND_COMMAND_ACTION_SCHEMA, + synchronous=True, +) +async def emontx_send_command_action_to_code( + config: ConfigType, action_id, template_arg, args +) -> None: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) + cg.add(var.set_command(template_)) + return var diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp new file mode 100644 index 0000000000..7a1b084fe0 --- /dev/null +++ b/esphome/components/emontx/emontx.cpp @@ -0,0 +1,116 @@ +#include "emontx.h" +#include "esphome/core/log.h" +#include "esphome/components/json/json_util.h" + +namespace esphome::emontx { + +static const char *const TAG = "emontx"; + +void EmonTx::setup() { this->buffer_pos_ = 0; } + +/** + * @brief Implements the main loop for parsing data from the serial port. + * + * @details Continuously processes incoming UART data line-by-line: + * 1. Fire on_data callbacks for all received lines + * 2. If line starts with '{', parse as JSON and update sensors/callbacks + */ +void EmonTx::loop() { + // Read all available data to prevent UART buffer overflow + while (this->available() > 0) { + uint8_t received = this->read(); + + if (received == '\r') { + continue; // Ignore CR + } else if (received == '\n') { + // End of line - process the buffer + if (this->buffer_pos_ > 0) { + // Null-terminate for safe logging and c_str() use + size_t len = this->buffer_pos_; + this->buffer_[len] = '\0'; + this->buffer_pos_ = 0; + + StringRef line(this->buffer_.data(), len); + ESP_LOGD(TAG, "Received line: %s", line.c_str()); + + // Fire data callbacks for all received lines + this->data_callbacks_.call(line); + + // Check if this line is JSON (starts with '{') + if (this->buffer_[0] == '{') { + ESP_LOGV(TAG, "Line is JSON, parsing..."); + this->parse_json_(this->buffer_.data(), len); + } + } + } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH); + this->buffer_pos_ = 0; + } else { + this->buffer_[this->buffer_pos_++] = static_cast(received); + } + } +} + +void EmonTx::parse_json_(const char *data, size_t len) { + bool success = json::parse_json(reinterpret_cast(data), len, [this, data, len](JsonObject root) { +#ifdef USE_SENSOR + for (auto &sensor_pair : this->sensors_) { + auto val = root[sensor_pair.first]; + if (val.is()) { + float value = val; + ESP_LOGV(TAG, "Updating sensor '%s' with value: %.2f", sensor_pair.first, value); + sensor_pair.second->publish_state(value); + } + } +#endif + + this->json_callbacks_.call(root, StringRef(data, len)); + return true; + }); + + if (!success) { + ESP_LOGW(TAG, "Failed to parse JSON"); + } +} + +/** + * @brief Logs the EmonTx component configuration details. + */ +void EmonTx::dump_config() { + ESP_LOGCONFIG(TAG, "EmonTx:"); + +#ifdef USE_SENSOR + ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); + for (const auto &sensor_pair : this->sensors_) { + ESP_LOGCONFIG(TAG, " Sensor: %s", sensor_pair.first); + } +#else + ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); +#endif +} + +/** + * @brief Sends a command string to the emonTx device via UART. + * + * @param command The command string to send (LF will be appended automatically). + */ +void EmonTx::send_command(const std::string &command) { + ESP_LOGD(TAG, "Sending command to emonTx: %s", command.c_str()); + this->write_str(command.c_str()); + this->write_byte('\n'); +} + +#ifdef USE_SENSOR +/** + * @brief Registers a sensor to receive updates for a specific JSON tag. + * + * @param tag_name The JSON key to monitor for this sensor (must be a string literal). + * @param sensor Pointer to the sensor that will receive value updates. + */ +void EmonTx::register_sensor(const char *tag_name, sensor::Sensor *sensor) { + ESP_LOGCONFIG(TAG, "Registering sensor for tag: %s", tag_name); + this->sensors_.emplace_back(tag_name, sensor); +} +#endif + +} // namespace esphome::emontx diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h new file mode 100644 index 0000000000..67e7f5bffc --- /dev/null +++ b/esphome/components/emontx/emontx.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/json/json_util.h" + +#include + +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + +namespace esphome::emontx { + +/// Maximum line length in bytes (plus one byte reserved for null terminator) +static constexpr size_t MAX_LINE_LENGTH = 1024; + +/** + * @class EmonTx + * @brief Main class for the EmonTx component. + * + * The EmonTx processes incoming data frames via UART, + * extracts tags and values, and publishes them to registered sensors. + */ +class EmonTx : public Component, public uart::UARTDevice { + public: + EmonTx() = default; + + void loop() override; + void setup() override; + void dump_config() override; + + template void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward(callback)); } + + template void add_on_data_callback(F &&callback) { this->data_callbacks_.add(std::forward(callback)); } + + // Send command to emonTx via UART + void send_command(const std::string &command); + +#ifdef USE_SENSOR + void init_sensors(size_t count) { this->sensors_.init(count); } + void register_sensor(const char *tag_name, sensor::Sensor *sensor); +#endif + + protected: + void parse_json_(const char *data, size_t len); + +#ifdef USE_SENSOR + FixedVector> sensors_{}; +#endif + LazyCallbackManager json_callbacks_; + LazyCallbackManager data_callbacks_; + uint16_t buffer_pos_{0}; + std::array buffer_{}; +}; + +// Action to send command to emonTx +template class EmonTxSendCommandAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, command) + + void play(const Ts &...x) override { this->parent_->send_command(this->command_.value(x...)); } +}; + +} // namespace esphome::emontx diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py new file mode 100644 index 0000000000..83a972c5e0 --- /dev/null +++ b/esphome/components/emontx/sensor/__init__.py @@ -0,0 +1,133 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PULSES, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) +from esphome.types import ConfigType + +from .. import CONF_EMONTX_ID, CONF_TAG_NAME, EmonTx, emontx_ns + +EmonTxSensor = emontx_ns.class_("EmonTxSensor", sensor.Sensor, cg.Component) + +# Define sensor type configurations by prefix +SENSOR_CONFIGS = { + "P": { + CONF_UNIT_OF_MEASUREMENT: UNIT_WATT, + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 0, + }, + "E": { + CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS, + CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + CONF_ACCURACY_DECIMALS: 0, + }, + "V": { + CONF_UNIT_OF_MEASUREMENT: UNIT_VOLT, + CONF_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, + "I": { + CONF_UNIT_OF_MEASUREMENT: UNIT_AMPERE, + CONF_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, + "T": { + CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS, + CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, +} + +# Pattern-based configurations +PATTERN_CONFIGS = { + "PULSE": { + CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, + CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_ACCURACY_DECIMALS: 0, + }, + "PF": { + CONF_UNIT_OF_MEASUREMENT: UNIT_EMPTY, + CONF_DEVICE_CLASS: DEVICE_CLASS_POWER_FACTOR, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, +} + +# Create a base schema that's flexible for any tag +BASE_SCHEMA = sensor.sensor_schema( + EmonTxSensor, + state_class=STATE_CLASS_MEASUREMENT, + accuracy_decimals=0, +).extend( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Required(CONF_TAG_NAME): cv.string, + } +) + + +def apply_tag_defaults(config: ConfigType) -> ConfigType: + """Apply defaults based on tag prefix if applicable, but don't restrict any tags.""" + tag = config[CONF_TAG_NAME] + + # Skip if tag is too short + if len(tag) < 2: + return config + + # Check if this tag starts with a known prefix + tag_upper = tag.upper() + + for pattern, pattern_config in PATTERN_CONFIGS.items(): + if tag_upper.startswith(pattern): + # Apply pattern defaults if not overridden by user + for key, value in pattern_config.items(): + if key not in config: + config[key] = value + return config + + # Only apply defaults for known prefixes with numeric indices + prefix = tag_upper[0] + if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit(): + # Apply defaults for known tag types, but only if not overridden by user + defaults = SENSOR_CONFIGS[prefix] + for key, value in defaults.items(): + if key not in config: + config[key] = value + + return config + + +CONFIG_SCHEMA = cv.All(BASE_SCHEMA, apply_tag_defaults) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + hub = await cg.get_variable(config[CONF_EMONTX_ID]) + cg.add(hub.register_sensor(config[CONF_TAG_NAME], var)) diff --git a/esphome/components/emontx/sensor/emontx_sensor.cpp b/esphome/components/emontx/sensor/emontx_sensor.cpp new file mode 100644 index 0000000000..142df0150e --- /dev/null +++ b/esphome/components/emontx/sensor/emontx_sensor.cpp @@ -0,0 +1,10 @@ +#include "emontx_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::emontx { + +static const char *const TAG = "emontx_sensor"; + +void EmonTxSensor::dump_config() { LOG_SENSOR(" ", "EmonTx Sensor", this); } + +} // namespace esphome::emontx diff --git a/esphome/components/emontx/sensor/emontx_sensor.h b/esphome/components/emontx/sensor/emontx_sensor.h new file mode 100644 index 0000000000..9714acdf0d --- /dev/null +++ b/esphome/components/emontx/sensor/emontx_sensor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::emontx { + +class EmonTxSensor : public sensor::Sensor, public Component { + public: + void dump_config() override; +}; + +} // namespace esphome::emontx diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 527bb4ebba..9c9dd025b1 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -82,12 +82,16 @@ def event_schema( return _EVENT_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EVENT, "add_on_event_callback", [(cg.StringRef, "event_type")] + ), +) + + @setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): - for conf in config.get(CONF_ON_EVENT, []): - await automation.build_callback_automation( - var, "add_on_event_callback", [(cg.StringRef, "event_type")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_event_types(event_types)) diff --git a/esphome/components/ezo/sensor.py b/esphome/components/ezo/sensor.py index 7c81f9c848..b931885149 100644 --- a/esphome/components/ezo/sensor.py +++ b/esphome/components/ezo/sensor.py @@ -38,33 +38,30 @@ CONFIG_SCHEMA = ( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CUSTOM, "add_custom_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation(CONF_ON_LED, "add_led_state_callback", [(bool, "x")]), + automation.CallbackAutomation( + CONF_ON_DEVICE_INFORMATION, + "add_device_infomation_callback", + [(cg.std_string, "x")], + ), + automation.CallbackAutomation( + CONF_ON_SLOPE, "add_slope_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation( + CONF_ON_CALIBRATION, "add_calibration_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation(CONF_ON_T, "add_t_callback", [(cg.std_string, "x")]), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) await i2c.register_i2c_device(var, config) - for conf in config.get(CONF_ON_CUSTOM, []): - await automation.build_callback_automation( - var, "add_custom_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_LED, []): - await automation.build_callback_automation( - var, "add_led_state_callback", [(bool, "x")], conf - ) - for conf in config.get(CONF_ON_DEVICE_INFORMATION, []): - await automation.build_callback_automation( - var, "add_device_infomation_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_SLOPE, []): - await automation.build_callback_automation( - var, "add_slope_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_CALIBRATION, []): - await automation.build_callback_automation( - var, "add_calibration_callback", [(cg.std_string, "x")], conf - ) - for conf in config.get(CONF_ON_T, []): - await automation.build_callback_automation( - var, "add_t_callback", [(cg.std_string, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 20b191a2b7..818a53c0ed 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -73,6 +73,15 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_INCREMENT, + "add_increment_callback", + [(cg.uint8, "x"), (cg.uint8, "target")], + ), +) + + async def to_code(config): if reset_count := config.get(CONF_RESETS_REQUIRED): var = cg.new_Pvariable( @@ -81,10 +90,4 @@ async def to_code(config): config[CONF_MAX_DELAY].total_seconds, ) await cg.register_component(var, config) - for conf in config.get(CONF_ON_INCREMENT, []): - await automation.build_callback_automation( - var, - "add_increment_callback", - [(cg.uint8, "x"), (cg.uint8, "target")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 0b01ba7cab..8d935a3c9e 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -116,6 +116,44 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_START, "add_on_finger_scan_start_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_MATCHED, + "add_on_finger_scan_matched_callback", + [(cg.uint16, "finger_id"), (cg.uint16, "confidence")], + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_UNMATCHED, + "add_on_finger_scan_unmatched_callback", + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_MISPLACED, + "add_on_finger_scan_misplaced_callback", + ), + automation.CallbackAutomation( + CONF_ON_FINGER_SCAN_INVALID, "add_on_finger_scan_invalid_callback" + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_SCAN, + "add_on_enrollment_scan_callback", + [(cg.uint8, "scan_num"), (cg.uint16, "finger_id")], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_DONE, + "add_on_enrollment_done_callback", + [(cg.uint16, "finger_id")], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_FAILED, + "add_on_enrollment_failed_callback", + [(cg.uint16, "finger_id")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -140,44 +178,7 @@ async def to_code(config): idle_period_to_sleep_ms = config[CONF_IDLE_PERIOD_TO_SLEEP] cg.add(var.set_idle_period_to_sleep_ms(idle_period_to_sleep_ms)) - for conf in config.get(CONF_ON_FINGER_SCAN_START, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_start_callback", [], conf - ) - for conf in config.get(CONF_ON_FINGER_SCAN_MATCHED, []): - await automation.build_callback_automation( - var, - "add_on_finger_scan_matched_callback", - [(cg.uint16, "finger_id"), (cg.uint16, "confidence")], - conf, - ) - for conf in config.get(CONF_ON_FINGER_SCAN_UNMATCHED, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_unmatched_callback", [], conf - ) - for conf in config.get(CONF_ON_FINGER_SCAN_MISPLACED, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_misplaced_callback", [], conf - ) - for conf in config.get(CONF_ON_FINGER_SCAN_INVALID, []): - await automation.build_callback_automation( - var, "add_on_finger_scan_invalid_callback", [], conf - ) - for conf in config.get(CONF_ON_ENROLLMENT_SCAN, []): - await automation.build_callback_automation( - var, - "add_on_enrollment_scan_callback", - [(cg.uint8, "scan_num"), (cg.uint16, "finger_id")], - conf, - ) - for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): - await automation.build_callback_automation( - var, "add_on_enrollment_done_callback", [(cg.uint16, "finger_id")], conf - ) - for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): - await automation.build_callback_automation( - var, "add_on_enrollment_failed_callback", [(cg.uint16, "finger_id")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/gl_r01_i2c/sensor.py b/esphome/components/gl_r01_i2c/sensor.py index 58db72540e..6a8d47213c 100644 --- a/esphome/components/gl_r01_i2c/sensor.py +++ b/esphome/components/gl_r01_i2c/sensor.py @@ -13,7 +13,7 @@ DEPENDENCIES = ["i2c"] gl_r01_i2c_ns = cg.esphome_ns.namespace("gl_r01_i2c") GLR01I2CComponent = gl_r01_i2c_ns.class_( - "GLR01I2CComponent", i2c.I2CDevice, cg.PollingComponent + "GLR01I2CComponent", sensor.Sensor, i2c.I2CDevice, cg.PollingComponent ) CONFIG_SCHEMA = ( diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index d72fe40dd2..0749d7e2a3 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -110,7 +110,7 @@ GRAPH_SCHEMA = cv.Schema( cv.Optional(CONF_MIN_RANGE): cv.float_range(min=0, min_included=False), cv.Optional(CONF_MAX_RANGE): cv.float_range(min=0, min_included=False), cv.Optional(CONF_TRACES): cv.ensure_list(GRAPH_TRACE_SCHEMA), - cv.Optional(CONF_LEGEND): cv.ensure_list(GRAPH_LEGEND_SCHEMA), + cv.Optional(CONF_LEGEND): GRAPH_LEGEND_SCHEMA, } ) @@ -192,7 +192,7 @@ async def to_code(config): cg.add(var.add_trace(tr)) # Add legend if CONF_LEGEND in config: - lgd = config[CONF_LEGEND][0] + lgd = config[CONF_LEGEND] legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend()) if CONF_NAME_FONT in lgd: font = await cg.get_variable(lgd[CONF_NAME_FONT]) diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 210e2f7bab..27a47953b3 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -80,11 +80,9 @@ async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): template_channel = await cg.templatable(config[CONF_CHANNEL], args, int) template_speed = await cg.templatable(config[CONF_SPEED], args, cg.uint16) - template_speed = ( - template_speed if config[CONF_DIRECTION] == "FORWARD" else -template_speed - ) cg.add(var.set_channel(template_channel)) cg.add(var.set_speed(template_speed)) + cg.add(var.set_direction(config[CONF_DIRECTION] == "FORWARD")) return var diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index a36cb85cff..bf47163226 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -168,11 +168,19 @@ class GROVETB6612FNGMotorRunAction : public Action, public Parentedforward_ = forward; } + void play(const Ts &...x) override { auto channel = this->channel_.value(x...); - auto speed = this->speed_.value(x...); + int16_t speed = this->speed_.value(x...); + if (!this->forward_) { + speed = -speed; + } this->parent_->dc_motor_run(channel, speed); } + + protected: + bool forward_{true}; }; template diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 9c2c999f25..424ef46392 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -215,9 +215,7 @@ CONFIG_SCHEMA = cv.All( { cv.Optional( CONF_CONTROL_METHOD, default="SET_GROUP_PARAMETERS" - ): cv.ensure_list( - cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True) - ), + ): cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True), cv.Optional(CONF_BEEPER): cv.invalid( f"The {CONF_BEEPER} option is deprecated, use beeper_on/beeper_off actions or beeper switch for a haier platform instead" ), @@ -456,6 +454,25 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_ALARM_START, + "add_alarm_start_callback", + [(cg.uint8, "code"), (cg.const_char_ptr, "message")], + ), + automation.CallbackAutomation( + CONF_ON_ALARM_END, + "add_alarm_end_callback", + [(cg.uint8, "code"), (cg.const_char_ptr, "message")], + ), + automation.CallbackAutomation( + CONF_ON_STATUS_MESSAGE, + "add_status_message_callback", + [(cg.const_char_ptr, "data"), (cg.size_t, "data_size")], + ), +) + + async def to_code(config): cg.add(haier_ns.init_haier_protocol_logging()) var = await climate.new_climate(config) @@ -497,26 +514,6 @@ async def to_code(config): cg.add( var.set_status_message_header_size(config[CONF_STATUS_MESSAGE_HEADER_SIZE]) ) - for conf in config.get(CONF_ON_ALARM_START, []): - await automation.build_callback_automation( - var, - "add_alarm_start_callback", - [(cg.uint8, "code"), (cg.const_char_ptr, "message")], - conf, - ) - for conf in config.get(CONF_ON_ALARM_END, []): - await automation.build_callback_automation( - var, - "add_alarm_end_callback", - [(cg.uint8, "code"), (cg.const_char_ptr, "message")], - conf, - ) - for conf in config.get(CONF_ON_STATUS_MESSAGE, []): - await automation.build_callback_automation( - var, - "add_status_message_callback", - [(cg.const_char_ptr, "data"), (cg.size_t, "data_size")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) # https://github.com/paveldn/HaierProtocol cg.add_library("pavlodn/HaierProtocol", "0.9.31") diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index c0349319d1..c1aa81f6d4 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -52,58 +52,53 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FACE_SCAN_MATCHED, + "add_on_face_scan_matched_callback", + [(cg.int16, "face_id"), (cg.std_string, "name")], + ), + automation.CallbackAutomation( + CONF_ON_FACE_SCAN_UNMATCHED, "add_on_face_scan_unmatched_callback" + ), + automation.CallbackAutomation( + CONF_ON_FACE_SCAN_INVALID, + "add_on_face_scan_invalid_callback", + [(cg.uint8, "error")], + ), + automation.CallbackAutomation( + CONF_ON_FACE_INFO, + "add_on_face_info_callback", + [ + (cg.int16, "status"), + (cg.int16, "left"), + (cg.int16, "top"), + (cg.int16, "right"), + (cg.int16, "bottom"), + (cg.int16, "yaw"), + (cg.int16, "pitch"), + (cg.int16, "roll"), + ], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_DONE, + "add_on_enrollment_done_callback", + [(cg.int16, "face_id"), (cg.uint8, "direction")], + ), + automation.CallbackAutomation( + CONF_ON_ENROLLMENT_FAILED, + "add_on_enrollment_failed_callback", + [(cg.uint8, "error")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []): - await automation.build_callback_automation( - var, - "add_on_face_scan_matched_callback", - [(cg.int16, "face_id"), (cg.std_string, "name")], - conf, - ) - - for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []): - await automation.build_callback_automation( - var, "add_on_face_scan_unmatched_callback", [], conf - ) - - for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []): - await automation.build_callback_automation( - var, "add_on_face_scan_invalid_callback", [(cg.uint8, "error")], conf - ) - - for conf in config.get(CONF_ON_FACE_INFO, []): - await automation.build_callback_automation( - var, - "add_on_face_info_callback", - [ - (cg.int16, "status"), - (cg.int16, "left"), - (cg.int16, "top"), - (cg.int16, "right"), - (cg.int16, "bottom"), - (cg.int16, "yaw"), - (cg.int16, "pitch"), - (cg.int16, "roll"), - ], - conf, - ) - - for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): - await automation.build_callback_automation( - var, - "add_on_enrollment_done_callback", - [(cg.int16, "face_id"), (cg.uint8, "direction")], - conf, - ) - - for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): - await automation.build_callback_automation( - var, "add_on_enrollment_failed_callback", [(cg.uint8, "error")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( @@ -136,7 +131,7 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), - cv.Required(CONF_FACE_ID): cv.templatable(cv.uint16_t), + cv.Required(CONF_FACE_ID): cv.templatable(cv.int_range(min=0, max=32767)), }, key=CONF_FACE_ID, ), diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index 6ccc00b41c..b9059c120f 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -12,7 +12,7 @@ CONF_SELECTS = [ "Simple", ] -LD2420Select = ld2420_ns.class_("LD2420Select", cg.Component) +LD2420Select = ld2420_ns.class_("LD2420Select", select.Select, cg.Component) CONFIG_SCHEMA = { cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component), @@ -28,7 +28,7 @@ async def to_code(config): if operating_mode_config := config.get(CONF_OPERATING_MODE): sel = await select.new_select( operating_mode_config, - options=[CONF_SELECTS], + options=CONF_SELECTS, ) await cg.register_parented(sel, config[CONF_LD2420_ID]) cg.add(LD2420_component.set_operating_mode_select(sel)) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 37bf12bafc..585c9f7bf5 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -44,11 +44,13 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_DATA, []): - await automation.build_callback_automation( - var, "add_on_data_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/libretiny/gpio.py b/esphome/components/libretiny/gpio.py index 9bad400eb7..9f8d96de24 100644 --- a/esphome/components/libretiny/gpio.py +++ b/esphome/components/libretiny/gpio.py @@ -41,7 +41,7 @@ def _lookup_board_pins(board): board_pins = component.board_pins.get(board, {}) # Resolve aliased board pins (shorthand when two boards have the same pin configuration) while isinstance(board_pins, str): - board_pins = board_pins[board_pins] + board_pins = component.board_pins[board_pins] return board_pins diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index acbbbb4de9..46c400cb0e 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LIGHTWAVERFComponent), cv.Optional(CONF_READ_PIN, default=13): pins.internal_gpio_input_pin_schema, - cv.Optional(CONF_WRITE_PIN, default=14): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_WRITE_PIN, default=14): pins.internal_gpio_output_pin_schema, } ).extend(cv.polling_component_schema("1s")) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 0df4b20cba..1a45896ac1 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -81,20 +81,23 @@ def lock_schema( return _LOCK_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_LOCK, + "add_on_state_callback", + forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED), + ), + automation.CallbackAutomation( + CONF_ON_UNLOCK, + "add_on_state_callback", + forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED), + ), +) + + @setup_entity("lock") async def _setup_lock_core(var, config): - for conf_key, state_enum in ( - (CONF_ON_LOCK, LockState.LOCK_STATE_LOCKED), - (CONF_ON_UNLOCK, LockState.LOCK_STATE_UNLOCKED), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, - "add_on_state_callback", - [], - conf, - forwarder=LockStateForwarder.template(state_enum), - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index 712810222c..cca9330e76 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -211,6 +211,16 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PS_HIGH_THRESHOLD, "add_on_ps_high_trigger_callback" + ), + automation.CallbackAutomation( + CONF_ON_PS_LOW_THRESHOLD, "add_on_ps_low_trigger_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -240,14 +250,7 @@ async def to_code(config): sens = await sensor.new_sensor(prox_cnt_config) cg.add(var.set_proximity_counts_sensor(sens)) - for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_high_trigger_callback", [], conf - ) - for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_low_trigger_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_ltr_type(config[CONF_TYPE])) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 57503772a1..893415f028 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -201,6 +201,16 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PS_HIGH_THRESHOLD, "add_on_ps_high_trigger_callback" + ), + automation.CallbackAutomation( + CONF_ON_PS_LOW_THRESHOLD, "add_on_ps_low_trigger_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -230,14 +240,7 @@ async def to_code(config): sens = await sensor.new_sensor(prox_cnt_config) cg.add(var.set_proximity_counts_sensor(sens)) - for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_high_trigger_callback", [], conf - ) - for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []): - await automation.build_callback_automation( - var, "add_on_ps_low_trigger_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_ltr_type(config[CONF_TYPE])) diff --git a/esphome/components/mcp3008/sensor/__init__.py b/esphome/components/mcp3008/sensor/__init__.py index e85ce2955d..2576ef50e5 100644 --- a/esphome/components/mcp3008/sensor/__init__.py +++ b/esphome/components/mcp3008/sensor/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = ( .extend( { cv.GenerateID(CONF_MCP3008_ID): cv.use_id(MCP3008), - cv.Required(CONF_NUMBER): cv.int_, + cv.Required(CONF_NUMBER): cv.int_range(min=0, max=7), cv.Optional(CONF_REFERENCE_VOLTAGE, default="3.3V"): cv.voltage, } ) diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 02bdbefed5..0d145d81d3 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -48,11 +48,11 @@ async def to_code(config): config[CONF_CHANNEL], ) if not config[CONF_TERMINAL_A]: - cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "a")) + cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("a"))) if not config[CONF_TERMINAL_B]: - cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "b")) + cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("b"))) if not config[CONF_TERMINAL_W]: - cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "w")) + cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("w"))) if CONF_INITIAL_VALUE in config: cg.add( parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE]) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 842f620dae..3c2e9029d6 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -69,7 +69,7 @@ StateEnterForwarder = media_player_ns.class_("StateEnterForwarder") MediaPlayerState = media_player_ns.enum("MediaPlayerState") # State triggers: (config_key, state enum or None for any-state) -_STATE_TRIGGERS = [ +_STATE_TRIGGERS = ( (CONF_ON_STATE, None), (CONF_ON_IDLE, MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), (CONF_ON_PLAY, MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), @@ -77,7 +77,7 @@ _STATE_TRIGGERS = [ (CONF_ON_ANNOUNCEMENT, MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING), (CONF_ON_TURN_ON, MediaPlayerState.MEDIA_PLAYER_STATE_ON), (CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), -] +) # State conditions that all share the same schema and codegen handler _STATE_CONDITIONS = [ @@ -102,17 +102,54 @@ VolumeSetAction = media_player_ns.class_( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder + ), + automation.CallbackAutomation( + CONF_ON_IDLE, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_IDLE + ), + ), + automation.CallbackAutomation( + CONF_ON_PLAY, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING + ), + ), + automation.CallbackAutomation( + CONF_ON_PAUSE, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED + ), + ), + automation.CallbackAutomation( + CONF_ON_ANNOUNCEMENT, + "add_on_state_callback", + forwarder=StateEnterForwarder.template( + MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING + ), + ), + automation.CallbackAutomation( + CONF_ON_TURN_ON, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_ON), + ), + automation.CallbackAutomation( + CONF_ON_TURN_OFF, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_OFF), + ), +) + + @setup_entity("media_player") async def setup_media_player_core_(var, config): - for conf_key, state_enum in _STATE_TRIGGERS: - for conf in config.get(conf_key, []): - if state_enum is None: - forwarder = StateAnyForwarder - else: - forwarder = StateEnterForwarder.template(state_enum) - await automation.build_callback_automation( - var, "add_on_state_callback", [], conf, forwarder=forwarder - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) async def register_media_player(var, config): diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 1ef359ea6c..63b127e63d 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema( is_polling_component=False, ) ) - .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range()}), + .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range(min=0x00, max=0xFF)}), } ) diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index d9722b5d48..e149ee3ce3 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -37,8 +37,12 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( { - cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range(), - cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range(), + cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range( + min=0x00, max=0xFF + ), + cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range( + min=0x00, max=0xFF + ), } ), } diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 9e332425a6..2af58a96be 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -205,6 +205,25 @@ async def add_modbus_base_properties( cg.add(var.set_template(template_)) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_COMMAND_SENT, + "add_on_command_sent_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + ), + automation.CallbackAutomation( + CONF_ON_ONLINE, + "add_on_online_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + ), + automation.CallbackAutomation( + CONF_ON_OFFLINE, + "add_on_offline_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) @@ -257,27 +276,7 @@ async def to_code(config): ) cg.add(var.add_server_register(server_register_var)) await register_modbus_device(var, config) - for conf in config.get(CONF_ON_COMMAND_SENT, []): - await automation.build_callback_automation( - var, - "add_on_command_sent_callback", - [(cg.int_, "function_code"), (cg.int_, "address")], - conf, - ) - for conf in config.get(CONF_ON_ONLINE, []): - await automation.build_callback_automation( - var, - "add_on_online_callback", - [(cg.int_, "function_code"), (cg.int_, "address")], - conf, - ) - for conf in config.get(CONF_ON_OFFLINE, []): - await automation.build_callback_automation( - var, - "add_on_offline_callback", - [(cg.int_, "function_code"), (cg.int_, "address")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) async def register_modbus_device(var, config): diff --git a/esphome/components/my9231/my9231.cpp b/esphome/components/my9231/my9231.cpp index 5b77a49e72..25f7e6925d 100644 --- a/esphome/components/my9231/my9231.cpp +++ b/esphome/components/my9231/my9231.cpp @@ -81,9 +81,9 @@ void MY9231OutputComponent::loop() { } this->update_ = false; } -void MY9231OutputComponent::set_channel_value_(uint8_t channel, uint16_t value) { +void MY9231OutputComponent::set_channel_value_(uint16_t channel, uint16_t value) { ESP_LOGV(TAG, "set channels %u to %u", channel, value); - uint8_t index = this->num_channels_ - channel - 1; + uint16_t index = this->num_channels_ - channel - 1; if (this->pwm_amounts_[index] != value) { this->update_ = true; } diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index 77c1259853..dff68d247c 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -30,7 +30,7 @@ class MY9231OutputComponent : public Component { class Channel : public output::FloatOutput { public: void set_parent(MY9231OutputComponent *parent) { parent_ = parent; } - void set_channel(uint8_t channel) { channel_ = channel; } + void set_channel(uint16_t channel) { channel_ = channel; } protected: void write_state(float state) override { @@ -39,13 +39,13 @@ class MY9231OutputComponent : public Component { } MY9231OutputComponent *parent_; - uint8_t channel_; + uint16_t channel_; }; protected: uint16_t get_max_amount_() const { return (uint32_t(1) << this->bit_depth_) - 1; } - void set_channel_value_(uint8_t channel, uint16_t value); + void set_channel_value_(uint16_t channel, uint16_t value); void init_chips_(uint8_t command); void write_word_(uint16_t value, uint8_t bits); void send_di_pulses_(uint8_t count); diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index 17f6c77e17..e039dae615 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -1,5 +1,8 @@ #pragma once + #include "esphome/core/automation.h" +#include "esphome/core/string_ref.h" + #include "nextion.h" namespace esphome::nextion { diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 7705b21b0b..74a50a95d4 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -19,6 +19,10 @@ CONF_MAX_COMMANDS_PER_LOOP = "max_commands_per_loop" CONF_MAX_QUEUE_AGE = "max_queue_age" CONF_MAX_QUEUE_SIZE = "max_queue_size" CONF_ON_BUFFER_OVERFLOW = "on_buffer_overflow" +CONF_ON_CUSTOM_BINARY_SENSOR = "on_custom_binary_sensor" +CONF_ON_CUSTOM_SENSOR = "on_custom_sensor" +CONF_ON_CUSTOM_SWITCH = "on_custom_switch" +CONF_ON_CUSTOM_TEXT_SENSOR = "on_custom_text_sensor" CONF_ON_PAGE = "on_page" CONF_ON_SETUP = "on_setup" CONF_ON_SLEEP = "on_sleep" diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 506eb1202b..4d42898a10 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -20,6 +20,10 @@ from .base_component import ( CONF_MAX_QUEUE_AGE, CONF_MAX_QUEUE_SIZE, CONF_ON_BUFFER_OVERFLOW, + CONF_ON_CUSTOM_BINARY_SENSOR, + CONF_ON_CUSTOM_SENSOR, + CONF_ON_CUSTOM_SWITCH, + CONF_ON_CUSTOM_TEXT_SENSOR, CONF_ON_PAGE, CONF_ON_SETUP, CONF_ON_SLEEP, @@ -88,6 +92,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAX_COMMANDS_PER_LOOP): cv.uint16_t, cv.Optional(CONF_MAX_QUEUE_SIZE): cv.positive_int, cv.Optional(CONF_ON_BUFFER_OVERFLOW): automation.validate_automation({}), + cv.Optional(CONF_ON_CUSTOM_BINARY_SENSOR): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CUSTOM_SENSOR): automation.validate_automation({}), + cv.Optional(CONF_ON_CUSTOM_SWITCH): automation.validate_automation({}), + cv.Optional(CONF_ON_CUSTOM_TEXT_SENSOR): automation.validate_automation({}), cv.Optional(CONF_ON_PAGE): automation.validate_automation({}), cv.Optional(CONF_ON_SETUP): automation.validate_automation({}), cv.Optional(CONF_ON_SLEEP): automation.validate_automation({}), @@ -144,6 +154,56 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_SETUP, "add_setup_state_callback"), + automation.CallbackAutomation(CONF_ON_SLEEP, "add_sleep_state_callback"), + automation.CallbackAutomation(CONF_ON_WAKE, "add_wake_state_callback"), + automation.CallbackAutomation( + CONF_ON_PAGE, "add_new_page_callback", [(cg.uint8, "x")] + ), + automation.CallbackAutomation( + CONF_ON_TOUCH, + "add_touch_event_callback", + [ + (cg.uint8, "page_id"), + (cg.uint8, "component_id"), + (cg.bool_, "touch_event"), + ], + ), + automation.CallbackAutomation( + CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_BINARY_SENSOR, + "add_custom_binary_sensor_callback", + [(cg.StringRef, "key"), (cg.bool_, "value")], + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_SENSOR, + "add_custom_sensor_callback", + [(cg.StringRef, "key"), (cg.int32, "value")], + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_SWITCH, + "add_custom_switch_callback", + [(cg.StringRef, "key"), (cg.bool_, "value")], + ), + automation.CallbackAutomation( + CONF_ON_CUSTOM_TEXT_SENSOR, + "add_custom_text_sensor_callback", + [(cg.StringRef, "key"), (cg.StringRef, "value")], + ), +) + +# Map custom trigger config keys to their conditional defines +_CUSTOM_TRIGGER_DEFINES = { + CONF_ON_CUSTOM_BINARY_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR", + CONF_ON_CUSTOM_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_SENSOR", + CONF_ON_CUSTOM_SWITCH: "USE_NEXTION_TRIGGER_CUSTOM_SWITCH", + CONF_ON_CUSTOM_TEXT_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR", +} + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await uart.register_uart_device(var, config) @@ -231,35 +291,8 @@ async def to_code(config): cg.add(var.set_max_commands_per_loop(max_commands_per_loop)) await display.register_display(var, config) + for conf_key, define_name in _CUSTOM_TRIGGER_DEFINES.items(): + if config.get(conf_key): + cg.add_define(define_name) - for conf in config.get(CONF_ON_SETUP, []): - await automation.build_callback_automation( - var, "add_setup_state_callback", [], conf - ) - for conf in config.get(CONF_ON_SLEEP, []): - await automation.build_callback_automation( - var, "add_sleep_state_callback", [], conf - ) - for conf in config.get(CONF_ON_WAKE, []): - await automation.build_callback_automation( - var, "add_wake_state_callback", [], conf - ) - for conf in config.get(CONF_ON_PAGE, []): - await automation.build_callback_automation( - var, "add_new_page_callback", [(cg.uint8, "x")], conf - ) - for conf in config.get(CONF_ON_TOUCH, []): - await automation.build_callback_automation( - var, - "add_touch_event_callback", - [ - (cg.uint8, "page_id"), - (cg.uint8, "component_id"), - (cg.bool_, "touch_event"), - ], - conf, - ) - for conf in config.get(CONF_ON_BUFFER_OVERFLOW, []): - await automation.build_callback_automation( - var, "add_buffer_overflow_event_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4a15cbe64f..b0e14b5ea3 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,8 +1,11 @@ #include "nextion.h" + #include + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/string_ref.h" #include "esphome/core/util.h" namespace esphome::nextion { @@ -706,7 +709,7 @@ void Nextion::process_nextion_commands_() { auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) < 1) { ESP_LOGE(TAG, "Bad switch data (0x90)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -715,6 +718,10 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Switch %s: %s", ONOFF(to_process[index] != 0), variable_name.c_str()); +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH + this->custom_switch_callback_.call(StringRef(variable_name), to_process[index] != 0); +#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH + for (auto *switchtype : this->switchtype_) { switchtype->process_bool(variable_name, to_process[index] != 0); } @@ -732,7 +739,7 @@ void Nextion::process_nextion_commands_() { auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) != 4) { ESP_LOGE(TAG, "Bad sensor data (0x91)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -744,6 +751,10 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Sensor: %s=%d", variable_name.c_str(), value); +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR + this->custom_sensor_callback_.call(StringRef(variable_name), value); +#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR + for (auto *sensor : this->sensortype_) { sensor->process_sensor(variable_name, value); } @@ -765,7 +776,7 @@ void Nextion::process_nextion_commands_() { auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) < 1) { ESP_LOGE(TAG, "Bad text data (0x92)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -781,6 +792,11 @@ void Nextion::process_nextion_commands_() { // nq->variable_name = variable_name; // nq->state = text_value; // this->textsensorq_.push_back(nq); + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + this->custom_text_sensor_callback_.call(StringRef(variable_name), StringRef(text_value)); +#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + for (auto *textsensortype : this->textsensortype_) { textsensortype->process_text(variable_name, text_value); } @@ -798,8 +814,8 @@ void Nextion::process_nextion_commands_() { // Get variable name auto index = to_process.find('\0'); if (index == std::string::npos || (to_process_length - index - 1) < 1) { - ESP_LOGE(TAG, "Bad binary data (0x92)"); - ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index); + ESP_LOGE(TAG, "Bad binary data (0x93)"); + ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index); break; } @@ -808,6 +824,10 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Binary sensor: %s=%s", variable_name.c_str(), ONOFF(to_process[index] != 0)); +#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + this->custom_binary_sensor_callback_.call(StringRef(variable_name), to_process[index] != 0); +#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + for (auto *binarysensortype : this->binarysensortype_) { binarysensortype->process_bool(&variable_name[0], to_process[index] != 0); } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index d910389289..c84a5cd49c 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -7,6 +7,7 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/components/uart/uart.h" #include "esphome/core/defines.h" +#include "esphome/core/string_ref.h" #include "esphome/core/time.h" #ifdef USE_NEXTION_WAVEFORM @@ -1183,6 +1184,59 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe this->buffer_overflow_callback_.add(std::forward(callback)); } + // Callbacks for Nextion "custom protocol" frames (0x90..0x93) +#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + /** Add a callback to be notified when Nextion sends a custom binary sensor protocol frame (0x93). + * + * This callback is invoked when a Nextion custom binary sensor frame is received, + * providing the component name as the key and the decoded boolean value. + * + * @param callback The void(const StringRef &key, bool value) callback. + */ + template void add_custom_binary_sensor_callback(F &&callback) { + this->custom_binary_sensor_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR + /** Add a callback to be notified when Nextion sends a custom sensor protocol frame (0x91). + * + * This callback is invoked when a Nextion custom sensor frame is received, + * providing the component name as the key and the decoded integer value. + * + * @param callback The void(StringRef key, int32_t value) callback. + */ + template void add_custom_sensor_callback(F &&callback) { + this->custom_sensor_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH + /** Add a callback to be notified when Nextion sends a custom switch protocol frame (0x90). + * + * This callback is invoked when a Nextion custom switch frame is received, + * providing the component name as the key and the decoded boolean value. + * + * @param callback The void(const StringRef &key, bool value) callback. + */ + template void add_custom_switch_callback(F &&callback) { + this->custom_switch_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH + +#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + /** Add a callback to be notified when Nextion sends a custom text sensor protocol frame (0x92). + * + * This callback is invoked when a Nextion custom text sensor frame is received, + * providing the component name as the key and the decoded text value. + * + * @param callback The void(const StringRef &key, const StringRef &value) callback. + */ + template void add_custom_text_sensor_callback(F &&callback) { + this->custom_text_sensor_callback_.add(std::forward(callback)); + } +#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + void update_all_components(); /** @@ -1535,6 +1589,18 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe CallbackManager page_callback_{}; CallbackManager touch_callback_{}; CallbackManager buffer_overflow_callback_{}; +#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR + CallbackManager custom_binary_sensor_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR + CallbackManager custom_sensor_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR +#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH + CallbackManager custom_switch_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH +#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR + CallbackManager custom_text_sensor_callback_{}; +#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR nextion_writer_t writer_; optional brightness_; diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 1a0dc42407..9fbaff6860 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -243,12 +243,16 @@ def number_schema( return _NUMBER_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_number_automations(var, config): - for conf in config.get(CONF_ON_VALUE, []): - await automation.build_callback_automation( - var, "add_on_state_callback", [(float, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 5b8294c70e..518d787d8a 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -105,6 +105,14 @@ async def online_image_action_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] + ), + automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), +) + + async def to_code(config): # Use the enhanced helper function to get all runtime image parameters settings = await runtime_image.process_runtime_image_config(config) @@ -139,12 +147,4 @@ async def to_code(config): else: cg.add(var.add_request_header(key, value)) - for conf in config.get(CONF_ON_DOWNLOAD_FINISHED, []): - await automation.build_callback_automation( - var, "add_on_finished_callback", [(bool, "cached")], conf - ) - - for conf in config.get(CONF_ON_ERROR, []): - await automation.build_callback_automation( - var, "add_on_error_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index e540edb91f..b6e156e7ff 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -51,7 +51,7 @@ PCA6416A_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=16), + cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { cv.Optional(CONF_INPUT, default=False): cv.boolean, diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 0d4de3cb73..1502158c29 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -21,7 +21,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(pcf8563Component), } -).extend(i2c.i2c_device_schema(0xA3)) +).extend(i2c.i2c_device_schema(0x51)) @automation.register_action( diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 902efd2279..d8a1e20db6 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -55,7 +55,7 @@ def validate_mode(value): PCF8574_PIN_SCHEMA = pins.gpio_base_schema( PCF8574GPIOPin, - cv.int_range(min=0, max=17), + cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], mode_validator=validate_mode, invertible=True, diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 4ccda49a72..f34df21647 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -49,6 +49,13 @@ def CONFIG_SCHEMA(conf): ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + async def setup_pn532(var, config): await cg.register_component(var, config) @@ -66,10 +73,7 @@ async def setup_pn532(var, config): trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf ) - for conf in config.get(CONF_ON_FINISHED_WRITE, []): - await automation.build_callback_automation( - var, "add_on_finished_write_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_condition( diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index c8723dc31c..9dd3e8c5b0 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -164,6 +164,16 @@ async def pn7150_simple_action_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + async def setup_pn7150(var, config): await cg.register_component(var, config) @@ -194,15 +204,7 @@ async def setup_pn7150(var, config): trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf ) - for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []): - await automation.build_callback_automation( - var, "add_on_emulated_tag_scan_callback", [], conf - ) - - for conf in config.get(CONF_ON_FINISHED_WRITE, []): - await automation.build_callback_automation( - var, "add_on_finished_write_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_condition( diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index e382594b93..ef14a29099 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -168,6 +168,16 @@ async def pn7160_simple_action_to_code(config, action_id, template_arg, args): return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + async def setup_pn7160(var, config): await cg.register_component(var, config) @@ -206,15 +216,7 @@ async def setup_pn7160(var, config): trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf ) - for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []): - await automation.build_callback_automation( - var, "add_on_emulated_tag_scan_callback", [], conf - ) - - for conf in config.get(CONF_ON_FINISHED_WRITE, []): - await automation.build_callback_automation( - var, "add_on_finished_write_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_condition( diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 4ee1e7891f..9ca47fe862 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -67,22 +67,26 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CODE_RECEIVED, + "add_on_code_received_callback", + [(RFBridgeData, "data")], + ), + automation.CallbackAutomation( + CONF_ON_ADVANCED_CODE_RECEIVED, + "add_on_advanced_code_received_callback", + [(RFBridgeAdvancedData, "data")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_CODE_RECEIVED, []): - await automation.build_callback_automation( - var, "add_on_code_received_callback", [(RFBridgeData, "data")], conf - ) - for conf in config.get(CONF_ON_ADVANCED_CODE_RECEIVED, []): - await automation.build_callback_automation( - var, - "add_on_advanced_code_received_callback", - [(RFBridgeAdvancedData, "data")], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index d88657e715..246db023f4 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -84,6 +84,14 @@ CONFIG_SCHEMA = cv.All( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_CLOCKWISE, "add_on_clockwise_callback"), + automation.CallbackAutomation( + CONF_ON_ANTICLOCKWISE, "add_on_anticlockwise_callback" + ), +) + + async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -104,14 +112,7 @@ async def to_code(config): if CONF_MAX_VALUE in config: cg.add(var.set_max_value(config[CONF_MAX_VALUE])) - for conf in config.get(CONF_ON_CLOCKWISE, []): - await automation.build_callback_automation( - var, "add_on_clockwise_callback", [], conf - ) - for conf in config.get(CONF_ON_ANTICLOCKWISE, []): - await automation.build_callback_automation( - var, "add_on_anticlockwise_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( @@ -119,7 +120,7 @@ async def to_code(config): RotaryEncoderSetValueAction, cv.Schema( { - cv.Required(CONF_ID): cv.use_id(sensor.Sensor), + cv.Required(CONF_ID): cv.use_id(RotaryEncoderSensor), cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), diff --git a/esphome/components/rp2040_pio/__init__.py b/esphome/components/rp2040_pio/__init__.py index 4bd46731df..eecfedaa75 100644 --- a/esphome/components/rp2040_pio/__init__.py +++ b/esphome/components/rp2040_pio/__init__.py @@ -1,6 +1,7 @@ import platform import esphome.codegen as cg +import esphome.config_validation as cv DEPENDENCIES = ["rp2040"] @@ -31,7 +32,13 @@ async def to_code(config): # "earlephilhower/tool-pioasm-rp2040-earlephilhower", # ], # ) - file = PIOASM_DOWNLOADS[platform.system().lower()][platform.machine().lower()] + os_name = platform.system().lower() + arch = platform.machine().lower() + if os_name not in PIOASM_DOWNLOADS or arch not in PIOASM_DOWNLOADS[os_name]: + raise cv.Invalid( + f"pioasm is not available for {platform.system()} {platform.machine()}" + ) + file = PIOASM_DOWNLOADS[os_name][arch] cg.add_platformio_option( "platform_packages", [f"earlephilhower/tool-pioasm-rp2040-earlephilhower@{PIOASM_REPO_BASE}/{file}"], diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 62f7fffdc9..274f059bd5 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -148,7 +148,6 @@ CHIPSETS = { "WS2812B": Chipset.CHIPSET_WS2812B, "SK6812": Chipset.CHIPSET_SK6812, "SM16703": Chipset.CHIPSET_SM16703, - "CUSTOM": Chipset.CHIPSET_CUSTOM, } diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 638e950ba6..c661aad972 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -71,6 +71,13 @@ FINAL_VALIDATE_SCHEMA = cv.Schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_FINISHED_PLAYBACK, "add_on_finished_playback_callback" + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -86,10 +93,7 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): - await automation.build_callback_automation( - var, "add_on_finished_playback_callback", [], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index da36d21eb7..6df0ba78b1 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -65,18 +65,22 @@ async def safe_mode_mark_successful_to_code(config, action_id, template_arg, arg return var +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_SAFE_MODE, "add_on_safe_mode_callback"), +) + + @coroutine_with_priority(CoroPriority.APPLICATION) async def to_code(config): if not config[CONF_DISABLED]: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if on_safe_mode_config := config.get(CONF_ON_SAFE_MODE): + if config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") - for conf in on_safe_mode_config: - await automation.build_callback_automation( - var, "add_on_safe_mode_callback", [], conf - ) + await automation.build_callback_automations( + var, config, _CALLBACK_AUTOMATIONS + ) condition = var.should_enter_safe_mode( config[CONF_NUM_ATTEMPTS], diff --git a/esphome/components/sdp3x/sensor.py b/esphome/components/sdp3x/sensor.py index 169ed374ed..be2eec7baf 100644 --- a/esphome/components/sdp3x/sensor.py +++ b/esphome/components/sdp3x/sensor.py @@ -14,7 +14,10 @@ CODEOWNERS = ["@Azimath"] sdp3x_ns = cg.esphome_ns.namespace("sdp3x") SDP3XComponent = sdp3x_ns.class_( - "SDP3XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice + "SDP3XComponent", + sensor.Sensor, + cg.PollingComponent, + sensirion_common.SensirionI2CDevice, ) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index e80470bde1..f71239d18c 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -33,6 +33,7 @@ CONFIG_SCHEMA = ( # This authentication mode requires that the device must have transmit and receive functionality, a parity mode of "NONE", and a stop bit of one. FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "seeed_mr24hpc1", + baud_rate=115200, require_tx=True, require_rx=True, parity="NONE", diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index c9fe3a2e6e..b44c5ce83d 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -62,8 +62,6 @@ void MR24HPC1Component::dump_config() { // Initialisation functions void MR24HPC1Component::setup() { - this->check_uart_settings(115200); - #ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); // Zero out the custom mode diff --git a/esphome/components/sen0321/sensor.py b/esphome/components/sen0321/sensor.py index e1c1d4e94b..3910e6e4c9 100644 --- a/esphome/components/sen0321/sensor.py +++ b/esphome/components/sen0321/sensor.py @@ -12,7 +12,7 @@ DEPENDENCIES = ["i2c"] sen0321_sensor_ns = cg.esphome_ns.namespace("sen0321_sensor") Sen0321Sensor = sen0321_sensor_ns.class_( - "Sen0321Sensor", cg.PollingComponent, i2c.I2CDevice + "Sen0321Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) CONFIG_SCHEMA = ( diff --git a/esphome/components/sen21231/sensor.py b/esphome/components/sen21231/sensor.py index 52cecbfb69..781a1213ac 100644 --- a/esphome/components/sen21231/sensor.py +++ b/esphome/components/sen21231/sensor.py @@ -8,7 +8,7 @@ DEPENDENCIES = ["i2c"] sen21231_sensor_ns = cg.esphome_ns.namespace("sen21231_sensor") Sen21231Sensor = sen21231_sensor_ns.class_( - "Sen21231Sensor", cg.PollingComponent, i2c.I2CDevice + "Sen21231Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) CONFIG_SCHEMA = ( diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 550e32c506..ecf51d5488 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -892,16 +892,19 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] + ), + automation.CallbackAutomation( + CONF_ON_RAW_VALUE, "add_on_raw_state_callback", [(float, "x")] + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_sensor_automations(var, config): - for conf_key, callback in ( - (CONF_ON_VALUE, "add_on_state_callback"), - (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, callback, [(float, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 91771047e1..ae7ee6fa59 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -48,34 +48,37 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_SMS_RECEIVED, + "add_on_sms_received_callback", + [(cg.std_string, "message"), (cg.std_string, "sender")], + ), + automation.CallbackAutomation( + CONF_ON_INCOMING_CALL, + "add_on_incoming_call_callback", + [(cg.std_string, "caller_id")], + ), + automation.CallbackAutomation( + CONF_ON_CALL_CONNECTED, "add_on_call_connected_callback" + ), + automation.CallbackAutomation( + CONF_ON_CALL_DISCONNECTED, "add_on_call_disconnected_callback" + ), + automation.CallbackAutomation( + CONF_ON_USSD_RECEIVED, + "add_on_ussd_received_callback", + [(cg.std_string, "ussd")], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_SMS_RECEIVED, []): - await automation.build_callback_automation( - var, - "add_on_sms_received_callback", - [(cg.std_string, "message"), (cg.std_string, "sender")], - conf, - ) - for conf in config.get(CONF_ON_INCOMING_CALL, []): - await automation.build_callback_automation( - var, "add_on_incoming_call_callback", [(cg.std_string, "caller_id")], conf - ) - for conf in config.get(CONF_ON_CALL_CONNECTED, []): - await automation.build_callback_automation( - var, "add_on_call_connected_callback", [], conf - ) - for conf in config.get(CONF_ON_CALL_DISCONNECTED, []): - await automation.build_callback_automation( - var, "add_on_call_disconnected_callback", [], conf - ) - for conf in config.get(CONF_ON_USSD_RECEIVED, []): - await automation.build_callback_automation( - var, "add_on_ussd_received_callback", [(cg.std_string, "ussd")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) SIM800L_SEND_SMS_SCHEMA = cv.Schema( diff --git a/esphome/components/sm16716/output.py b/esphome/components/sm16716/output.py index 50f6ec759f..2cfc38f5cc 100644 --- a/esphome/components/sm16716/output.py +++ b/esphome/components/sm16716/output.py @@ -14,7 +14,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM16716_ID): cv.use_id(SM16716), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=254), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sm2135/output.py b/esphome/components/sm2135/output.py index 71c4af2253..a4ac7fc7da 100644 --- a/esphome/components/sm2135/output.py +++ b/esphome/components/sm2135/output.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM2135_ID): cv.use_id(SM2135), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sm2235/output.py b/esphome/components/sm2235/output.py index 2a9698d645..b17af2b1e0 100644 --- a/esphome/components/sm2235/output.py +++ b/esphome/components/sm2235/output.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM2235_ID): cv.use_id(SM2235), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sm2335/output.py b/esphome/components/sm2335/output.py index ef7fec7307..7fd00917bd 100644 --- a/esphome/components/sm2335/output.py +++ b/esphome/components/sm2335/output.py @@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_SM2335_ID): cv.use_id(SM2335), cv.Required(CONF_ID): cv.declare_id(Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 1b7f9da4fb..d25e883fa1 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -31,23 +31,26 @@ CONFIG_SCHEMA = ( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DATA, + "add_on_data_callback", + [ + ( + cg.std_vector.template(cg.uint8).operator("ref").operator("const"), + "bytes", + ), + (cg.bool_, "valid"), + ], + ), +) + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for conf in config.get(CONF_ON_DATA, []): - await automation.build_callback_automation( - var, - "add_on_data_callback", - [ - ( - cg.std_vector.template(cg.uint8).operator("ref").operator("const"), - "bytes", - ), - (cg.bool_, "valid"), - ], - conf, - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) def obis_code(value): diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c4dd4856e3..5a63cbfb9f 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -121,17 +121,26 @@ def switch_schema( return _SWITCH_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(bool, "x")] + ), + automation.CallbackAutomation( + CONF_ON_TURN_ON, + "add_on_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_TURN_OFF, + "add_on_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_switch_automations(var, config): - for conf_key, args, forwarder in ( - (CONF_ON_STATE, [(bool, "x")], None), - (CONF_ON_TURN_ON, [], automation.TriggerOnTrueForwarder), - (CONF_ON_TURN_OFF, [], automation.TriggerOnFalseForwarder), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, "add_on_state_callback", args, conf, forwarder=forwarder - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @setup_entity("switch") diff --git a/esphome/components/tc74/sensor.py b/esphome/components/tc74/sensor.py index 18fc2d9a42..18a94016fb 100644 --- a/esphome/components/tc74/sensor.py +++ b/esphome/components/tc74/sensor.py @@ -11,7 +11,9 @@ CODEOWNERS = ["@sethgirvan"] DEPENDENCIES = ["i2c"] tc74_ns = cg.esphome_ns.namespace("tc74") -TC74Component = tc74_ns.class_("TC74Component", cg.PollingComponent, i2c.I2CDevice) +TC74Component = tc74_ns.class_( + "TC74Component", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 5b07dd2915..94014e8d20 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -184,16 +184,19 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_VALUE, "add_on_state_callback", [(cg.std_string, "x")] + ), + automation.CallbackAutomation( + CONF_ON_RAW_VALUE, "add_on_raw_state_callback", [(cg.std_string, "x")] + ), +) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_text_sensor_automations(var, config): - for conf_key, callback in ( - (CONF_ON_VALUE, "add_on_state_callback"), - (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), - ): - for conf in config.get(conf_key, []): - await automation.build_callback_automation( - var, callback, [(cg.std_string, "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @setup_entity("text_sensor") diff --git a/esphome/components/tlc5947/output/__init__.py b/esphome/components/tlc5947/output/__init__.py index a1290add81..6bea1546d3 100644 --- a/esphome/components/tlc5947/output/__init__.py +++ b/esphome/components/tlc5947/output/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_TLC5947_ID): cv.use_id(TLC5947), cv.Required(CONF_ID): cv.declare_id(TLC5947Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.uint16_t, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tlc5947/output/tlc5947_output.h b/esphome/components/tlc5947/output/tlc5947_output.h index 5b2c51020c..0faec96acb 100644 --- a/esphome/components/tlc5947/output/tlc5947_output.h +++ b/esphome/components/tlc5947/output/tlc5947_output.h @@ -11,11 +11,11 @@ namespace tlc5947 { class TLC5947Channel : public output::FloatOutput, public Parented { public: - void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_channel(uint16_t channel) { this->channel_ = channel; } protected: void write_state(float state) override; - uint8_t channel_; + uint16_t channel_; }; } // namespace tlc5947 diff --git a/esphome/components/tlc5971/output/__init__.py b/esphome/components/tlc5971/output/__init__.py index ae000ae0a9..854fbbd810 100644 --- a/esphome/components/tlc5971/output/__init__.py +++ b/esphome/components/tlc5971/output/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.GenerateID(CONF_TLC5971_ID): cv.use_id(TLC5971), cv.Required(CONF_ID): cv.declare_id(TLC5971Channel), - cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535), + cv.Required(CONF_CHANNEL): cv.uint16_t, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index 944ee19b2d..ca3099e7b2 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -11,11 +11,11 @@ namespace tlc5971 { class TLC5971Channel : public output::FloatOutput, public Parented { public: - void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_channel(uint16_t channel) { this->channel_ = channel; } protected: void write_state(float state) override; - uint8_t channel_; + uint16_t channel_; }; } // namespace tlc5971 diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 17bbf19c9e..5dfd188f0f 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -130,12 +130,9 @@ async def to_code(config): if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) - if on_receive := config.get(CONF_ON_RECEIVE): - on_receive = on_receive[0] - trigger_id = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID]) - trigger = await automation.build_automation( - trigger_id, trigger_argtype, on_receive - ) + for conf in config.get(CONF_ON_RECEIVE, []): + trigger_id = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + trigger = await automation.build_automation(trigger_id, trigger_argtype, conf) trigger_lambda = await cg.process_lambda( trigger.trigger( cg.std_vector.template(cg.uint8)( @@ -146,6 +143,7 @@ async def to_code(config): listener_argtype, ) cg.add(var.add_listener(trigger_lambda)) + if config.get(CONF_ON_RECEIVE): cg.add(var.set_should_listen()) diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 5d5bd8dca3..9da3494329 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -71,9 +71,11 @@ def _validate_load_certificate(value): def validate_certificate(value): + # _validate_load_certificate already calls cv.file_() internally, + # but returns the parsed certificate object. We re-call cv.file_() + # to get the resolved path string that the bundle walker can discover. _validate_load_certificate(value) - # Validation result should be the path, not the loaded certificate - return value + return str(cv.file_(value)) def _validate_load_private_key(key, cert_pw): diff --git a/esphome/components/xiaomi_mue4094rt/binary_sensor.py b/esphome/components/xiaomi_mue4094rt/binary_sensor.py index 911d179d8b..c5d93384c9 100644 --- a/esphome/components/xiaomi_mue4094rt/binary_sensor.py +++ b/esphome/components/xiaomi_mue4094rt/binary_sensor.py @@ -1,3 +1,4 @@ +from esphome import core import esphome.codegen as cg from esphome.components import binary_sensor, esp32_ble_tracker import esphome.config_validation as cv @@ -21,9 +22,10 @@ CONFIG_SCHEMA = cv.All( .extend( { cv.Required(CONF_MAC_ADDRESS): cv.mac_address, - cv.Optional( - CONF_TIMEOUT, default="5s" - ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TIMEOUT, default="5s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=core.TimePeriod(milliseconds=65535)), + ), } ) .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 7805de98db..b0bd9e6231 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1468,17 +1468,53 @@ hex_uint64_t = hex_int_range(min=0, max=18446744073709551615) i2c_address = hex_uint8_t -def percentage(value): +def percentage(value: object) -> float: """Validate that the value is a percentage. - The resulting value is an integer in the range 0.0 to 1.0. + The resulting value is a float in the range 0.0 to 1.0. """ - value = possibly_negative_percentage(value) + value = _parse_percentage(value) return zero_to_one_float(value) -def possibly_negative_percentage(value): - has_percent_sign = False +def possibly_negative_percentage(value: object) -> float: + """Validate that the value is a possibly negative percentage. + + The resulting value is a float in the range -1.0 to 1.0. + """ + value = _parse_percentage(value) + return negative_one_to_one_float(value) + + +def unbounded_percentage(value: object) -> float: + """Validate that the value is a percentage, allowing values above 100%. + + The resulting value is a non-negative float with no upper bound. + For example, "150%" returns 1.5 and "50%" returns 0.5. + """ + value = _parse_percentage(value) + if value < 0: + raise Invalid("Percentage must not be negative") + return value + + +def unbounded_possibly_negative_percentage(value: object) -> float: + """Validate that the value is a possibly negative percentage without bounds. + + The resulting value is an unbounded float. + For example, "200%" returns 2.0 and "-150%" returns -1.5. + """ + return _parse_percentage(value) + + +def _parse_percentage(value: object) -> float: + """Parse a percentage string or number into a float. + + Handles both "50%" style strings and raw float values. + Values without a percent sign above 1.0 or below -1.0 are rejected + to prevent user mistakes (e.g. writing 50 instead of 50%). + """ + has_percent_sign: bool = False if isinstance(value, str): try: if value.endswith("%"): @@ -1490,21 +1526,16 @@ def possibly_negative_percentage(value): # pylint: disable=raise-missing-from raise Invalid("invalid number") try: - if value > 1: - msg = "Percentage must not be higher than 100%." - if not has_percent_sign: - msg += " Please put a percent sign after the number!" - raise Invalid(msg) - if value < -1: - msg = "Percentage must not be smaller than -100%." - if not has_percent_sign: - msg += " Please put a percent sign after the number!" - raise Invalid(msg) + if not has_percent_sign and (value > 1 or value < -1): + raise Invalid( + "Percentage value must use a percent sign for values " + "outside -1.0 to 1.0. Please put a percent sign after the number!" + ) except TypeError: raise Invalid( # pylint: disable=raise-missing-from - "Expected percentage or float between -1.0 and 1.0" + "Expected percentage or float" ) - return negative_one_to_one_float(value) + return float(value) def percentage_int(value): diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9c90790f3a..4939c194e3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,6 +123,10 @@ #define USE_NEXTION_MAX_COMMANDS_PER_LOOP #define USE_NEXTION_MAX_QUEUE_SIZE #define USE_NEXTION_TFT_UPLOAD +#define USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR +#define USE_NEXTION_TRIGGER_CUSTOM_SENSOR +#define USE_NEXTION_TRIGGER_CUSTOM_SWITCH +#define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR #define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e001316a22..a24c1ebccb 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import suppress +from collections.abc import Callable, Generator +from contextlib import contextmanager, suppress import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -44,6 +44,27 @@ _LOGGER = logging.getLogger(__name__) SECRET_YAML = "secrets.yaml" _SECRET_CACHE = {} _SECRET_VALUES = {} +# Not thread-safe — config processing is single-threaded today. +_load_listeners: list[Callable[[Path], None]] = [] + + +@contextmanager +def track_yaml_loads() -> Generator[list[Path]]: + """Context manager that records every file loaded by the YAML loader. + + Yields a list that is populated with resolved Path objects for every + file loaded through ``_load_yaml_internal`` while the context is active. + """ + loaded: list[Path] = [] + + def _on_load(fname: Path) -> None: + loaded.append(Path(fname).resolve()) + + _load_listeners.append(_on_load) + try: + yield loaded + finally: + _load_listeners.remove(_on_load) class ESPHomeDataBase: @@ -466,6 +487,8 @@ def load_yaml(fname: Path, clear_secrets: bool = True) -> Any: def _load_yaml_internal(fname: Path) -> Any: """Load a YAML file.""" + for listener in _load_listeners: + listener(fname) try: with fname.open(encoding="utf-8") as f_handle: return parse_yaml(fname, f_handle) @@ -473,10 +496,10 @@ def _load_yaml_internal(fname: Path) -> Any: raise EsphomeError(f"Error reading file {fname}: {err}") from err -def parse_yaml( - file_name: Path, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal -) -> Any: +def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any: """Parse a YAML file.""" + if yaml_loader is None: + yaml_loader = _load_yaml_internal try: return _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, yaml_loader diff --git a/requirements_test.txt b/requirements_test.txt index a191378dd7..eeee3434ce 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.2 +pytest==9.0.3 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.3.0 diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml new file mode 100644 index 0000000000..5c25e37abb --- /dev/null +++ b/tests/components/emontx/common.yaml @@ -0,0 +1,25 @@ +button: + - platform: template + name: Send command test + on_press: + - emontx.send_command: + id: test_emontx + command: "v" + +emontx: + id: test_emontx + on_json: + - then: + - logger.log: "Got JSON" + on_data: + - then: + - logger.log: + format: "Got data: %s" + args: [data.c_str()] + +sensor: + - platform: emontx + name: Power + tag_name: P1 + emontx_id: test_emontx + unit_of_measurement: W diff --git a/tests/components/emontx/test.esp32-idf.yaml b/tests/components/emontx/test.esp32-idf.yaml new file mode 100644 index 0000000000..3a3747f3a5 --- /dev/null +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/emontx/test.esp8266-ard.yaml b/tests/components/emontx/test.esp8266-ard.yaml new file mode 100644 index 0000000000..31c5731589 --- /dev/null +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/emontx/test.rp2040-ard.yaml b/tests/components/emontx/test.rp2040-ard.yaml new file mode 100644 index 0000000000..ff55e8263d --- /dev/null +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/grove_tb6612fng/common.yaml b/tests/components/grove_tb6612fng/common.yaml index 52d5ead96e..7c6d65e9a6 100644 --- a/tests/components/grove_tb6612fng/common.yaml +++ b/tests/components/grove_tb6612fng/common.yaml @@ -6,6 +6,11 @@ esphome: speed: 255 direction: BACKWARD id: test_motor + - grove_tb6612fng.run: + channel: 0 + speed: !lambda "return 200;" + direction: BACKWARD + id: test_motor - grove_tb6612fng.stop: channel: 1 id: test_motor diff --git a/tests/components/mcp4461/common.yaml b/tests/components/mcp4461/common.yaml index 92fd789dcb..71e2528aa4 100644 --- a/tests/components/mcp4461/common.yaml +++ b/tests/components/mcp4461/common.yaml @@ -22,3 +22,11 @@ output: id: digipot_wiper_4 mcp4461_id: mcp4461_digipot_01 channel: D + + - platform: mcp4461 + id: digipot_wiper_5 + mcp4461_id: mcp4461_digipot_01 + channel: A + terminal_a: false + terminal_b: false + terminal_w: false diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d9493db50c..0616b9a41a 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -286,6 +286,31 @@ display: on_buffer_overflow: then: logger.log: "Nextion reported a buffer overflow!" + on_custom_text_sensor: + then: + - lambda: |- + // key: StringRef, value: StringRef + if (key == "csv") { + // parse value here, or forward to your own component + ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); + } + on_custom_sensor: + then: + - lambda: |- + // key: StringRef, value: int32_t + if (key == "temperature_raw") { + ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value); + } + on_custom_binary_sensor: + then: + - lambda: |- + if (key == "btn1") { + ESP_LOGD("nextion.btn", "btn1=%s", ONOFF(value)); + } + on_custom_switch: + then: + - lambda: |- + ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); on_page: then: lambda: 'ESP_LOGD("display","Display shows new page %u", x);' @@ -304,8 +329,8 @@ display: on_wake: then: lambda: 'ESP_LOGD("display","Display woke up");' - update_interval: 5s start_up_page: 1 startup_override_ms: 10000ms # Wait 10s for display ready touch_sleep_timeout: 3 + update_interval: 5s wake_up_page: 2 diff --git a/tests/test_build_components/common/uart_115200/esp32-ard.yaml b/tests/test_build_components/common/uart_115200/esp32-ard.yaml index 9102910f31..108f12110d 100644 --- a/tests/test_build_components/common/uart_115200/esp32-ard.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml b/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml index 87a969c6a3..5176a4e8e2 100644 --- a/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-c3-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml b/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml index f3768592e5..f61d01d206 100644 --- a/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-c3-idf.yaml @@ -10,3 +10,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp32-idf.yaml b/tests/test_build_components/common/uart_115200/esp32-idf.yaml index e405f74fe7..b432d31a7e 100644 --- a/tests/test_build_components/common/uart_115200/esp32-idf.yaml +++ b/tests/test_build_components/common/uart_115200/esp32-idf.yaml @@ -10,3 +10,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/esp8266-ard.yaml b/tests/test_build_components/common/uart_115200/esp8266-ard.yaml index 2dcf1c4a5d..c4b9170c2f 100644 --- a/tests/test_build_components/common/uart_115200/esp8266-ard.yaml +++ b/tests/test_build_components/common/uart_115200/esp8266-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/test_build_components/common/uart_115200/rp2040-ard.yaml b/tests/test_build_components/common/uart_115200/rp2040-ard.yaml index 62a7b5aed2..874b09217b 100644 --- a/tests/test_build_components/common/uart_115200/rp2040-ard.yaml +++ b/tests/test_build_components/common/uart_115200/rp2040-ard.yaml @@ -9,3 +9,4 @@ uart: tx_pin: ${tx_pin} rx_pin: ${rx_pin} baud_rate: 115200 + rx_buffer_size: 2048 diff --git a/tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem b/tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem new file mode 100644 index 0000000000..6d200b15ef --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/certs/ca_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzjCCAbagAwIBAgIUW3BzjtekVgMj12/oeXawSswGyXMwDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWRVNQSG9tZSBCdW5kbGUgVGVzdCBDQTAeFw0yNjAyMDYx +MzMxMTZaFw0yNzAyMDYxMzMxMTZaMCExHzAdBgNVBAMMFkVTUEhvbWUgQnVuZGxl +IFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDG62vBFkGn +hEu54gh2A7b1ZwesVadZ6u0iaVO7GSWiI0o4nb6xv7ULZbGrgsKNIO6qCV4VSR3p +BfMhF5dFy8kkMzA8dKZMk16tygzocdNum2QQ8BHyIsATL7SGZ33si9Alp30gXv6h +XSlEKYDKHFavkDhWPFNa5+oeHbMS/MxjpOUXIpq32VaFpJr427d9Y9wGjuK8B7Gp +CI5Ub1g2dpC9xSHqQKD3JZokmtc70+mD74AcNWbyxWp0bkW9wOfNJJnAoiwhJxQ8 +yfE37UsUIVc8014NhdhU1K/S0iQuOKfGX1L/GAshv8syQIcDfzJuJdX+5E/leAYD +UEKqRkcLT+D5AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAF1HpJ6d+W5WrzOQrGej +41pxCDeJ9tSiSj/KtvJfjEVIpg0hMRTY7nSL7OAg9KGESfx4u1jMwVnyOv34br5B +DTlRl+wF2k7Ip8CNnyZfCC+1SVQZpUt1mVNz8BhIZZ9/a830wCILNQQrVKkSeNBk +SEc1qTt4mIhQZ+M422qAswluv4fz/FW1f4oB9KhCpzUCANjmyERnqTnImjnJu8h0 +jbPNnNsN+G+Roju8UD/7atWYfAUmDjHx72Ci/5G9SzoM5fhgxxu43XYd5RW5wBzt +j4KdKdYlDtOL62mRPKWd40uGnJcieUjisU7noRn0ErMgbUlhLdbXT9X7aNborZcu +x6I= +-----END CERTIFICATE----- diff --git a/tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem b/tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem new file mode 100644 index 0000000000..6d200b15ef --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/certs/client_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzjCCAbagAwIBAgIUW3BzjtekVgMj12/oeXawSswGyXMwDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWRVNQSG9tZSBCdW5kbGUgVGVzdCBDQTAeFw0yNjAyMDYx +MzMxMTZaFw0yNzAyMDYxMzMxMTZaMCExHzAdBgNVBAMMFkVTUEhvbWUgQnVuZGxl +IFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDG62vBFkGn +hEu54gh2A7b1ZwesVadZ6u0iaVO7GSWiI0o4nb6xv7ULZbGrgsKNIO6qCV4VSR3p +BfMhF5dFy8kkMzA8dKZMk16tygzocdNum2QQ8BHyIsATL7SGZ33si9Alp30gXv6h +XSlEKYDKHFavkDhWPFNa5+oeHbMS/MxjpOUXIpq32VaFpJr427d9Y9wGjuK8B7Gp +CI5Ub1g2dpC9xSHqQKD3JZokmtc70+mD74AcNWbyxWp0bkW9wOfNJJnAoiwhJxQ8 +yfE37UsUIVc8014NhdhU1K/S0iQuOKfGX1L/GAshv8syQIcDfzJuJdX+5E/leAYD +UEKqRkcLT+D5AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAF1HpJ6d+W5WrzOQrGej +41pxCDeJ9tSiSj/KtvJfjEVIpg0hMRTY7nSL7OAg9KGESfx4u1jMwVnyOv34br5B +DTlRl+wF2k7Ip8CNnyZfCC+1SVQZpUt1mVNz8BhIZZ9/a830wCILNQQrVKkSeNBk +SEc1qTt4mIhQZ+M422qAswluv4fz/FW1f4oB9KhCpzUCANjmyERnqTnImjnJu8h0 +jbPNnNsN+G+Roju8UD/7atWYfAUmDjHx72Ci/5G9SzoM5fhgxxu43XYd5RW5wBzt +j4KdKdYlDtOL62mRPKWd40uGnJcieUjisU7noRn0ErMgbUlhLdbXT9X7aNborZcu +x6I= +-----END CERTIFICATE----- diff --git a/tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem b/tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem new file mode 100644 index 0000000000..6182f45d8b --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/certs/client_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxutrwRZBp4RLueIIdgO29WcHrFWnWertImlTuxkloiNKOJ2+ +sb+1C2Wxq4LCjSDuqgleFUkd6QXzIReXRcvJJDMwPHSmTJNercoM6HHTbptkEPAR +8iLAEy+0hmd97IvQJad9IF7+oV0pRCmAyhxWr5A4VjxTWufqHh2zEvzMY6TlFyKa +t9lWhaSa+Nu3fWPcBo7ivAexqQiOVG9YNnaQvcUh6kCg9yWaJJrXO9Ppg++AHDVm +8sVqdG5FvcDnzSSZwKIsIScUPMnxN+1LFCFXPNNeDYXYVNSv0tIkLjinxl9S/xgL +Ib/LMkCHA38ybiXV/uRP5XgGA1BCqkZHC0/g+QIDAQABAoIBAEpsFwcJNCwf95MG +qcK5lhCPaRQFgdTG68ylmoGUIXvddy3ies+W2X33oLb5958ElLaCRbRyBCJEKxgU +8vBWk50bF69uty9MLa6YuyaWO5QUyCX8I8KzVKh4/zIP81F2Z7xGwy5CzEKED+Xk +Hz6+xoHt094TuN34iaOV2gM/GJsok4Wp/lzsuT3X6i3Nad9YGrV2yL/wv5c542bw +vrFDtYQ/+ADZZPW4+xK0ShiarSqV3iXB2cEjc4JX7yLX1hB4LY8VHRzl+Byjdl0/ +lheiIesl5htl82SFxquZDimDsbilTm7TLW2bbm3b3/oC7DchTx6COBjp90VJqk3R +QrO5dicCgYEA80pyA7tCB0bGnJ7KWkteKddyOdakeYeM7Bpfv17qbCm9ciMw9nqt +KJVZPtAuqZGTpfSJseOCIyz9zloB79hVJ3mdWpGJVvmNM5H+BJyCciXpwfqp64QG +1gMqGlSy/MwsZHqNCsOIvrzH09GFN0LSPNKeXN7GNAtU1vI5s7Xf158CgYEA0U+Y +Qe1qJY4m597spHNFfkGznoFXAjHOoWYHv95902cH6JD4GnYPfwFXxgFsrJhFaFMC +jXlT0fRFAIe4NuUJhGD6TYSJqsFkH3xJkAepvKpfjM5qJ7+PQHRnED/E5OS2Nj0R ++cxBhTEWTw9YiOFBRbj6hlphkj8izVGJZ2pL4GcCgYEApsjiYKx/F33tqnExR7Vj +WEvagswi9S137mQmP4tSKdRzi0uUxWRUUP4RsH4HfzfNgHej7c+J55Nwa4ZIzaQA +vI8i0HP1MyrhIflzqrWgt6BGIDU3R7268fw5YNOv4J4X0Moy5q4lkJzaYNvB96BX +gFrjNceDGSqrfq+P3yNP0QECgYBNQfHTM8ygPA4EO/Zg5ONbrOidsuPovXWlgUGP +ApKy+y6iGxBYxAcIO/in71KrijDkRu+ERKo5rs3hWjcWnAedQyZggnFGA8fvDzMf +5JQ0PTazhGUOcthvVAfOqZsFWZ4f+v6tk0UD4pB3chSdwXcUQyjFeorVLlSsMFJl +R4jmNQKBgG38YFR2bqIc7jJItr+34POXdJ4te8Dm1jJHbo8xXsnjVSaxjc5PGs3p +OuJpwuMwzEuFEnE7XLkQxTJw54OBLMmDgK0XUOPDq6eLzrKkW5NlpejqaQV9Piyo +q1kqbJan20jfJQUGTcX7FXHMUThzqJltHILR1GTW6I9z4k8xdsDY +-----END RSA PRIVATE KEY----- diff --git a/tests/unit_tests/fixtures/bundle/assets/fonts/test_font.ttf b/tests/unit_tests/fixtures/bundle/assets/fonts/test_font.ttf new file mode 100644 index 0000000000..4066b0a988 Binary files /dev/null and b/tests/unit_tests/fixtures/bundle/assets/fonts/test_font.ttf differ diff --git a/tests/unit_tests/fixtures/bundle/assets/images/animation.gif b/tests/unit_tests/fixtures/bundle/assets/images/animation.gif new file mode 100644 index 0000000000..9932e77448 Binary files /dev/null and b/tests/unit_tests/fixtures/bundle/assets/images/animation.gif differ diff --git a/tests/unit_tests/fixtures/bundle/assets/images/logo.png b/tests/unit_tests/fixtures/bundle/assets/images/logo.png new file mode 100644 index 0000000000..bd2fd54783 Binary files /dev/null and b/tests/unit_tests/fixtures/bundle/assets/images/logo.png differ diff --git a/tests/unit_tests/fixtures/bundle/assets/web/custom.css b/tests/unit_tests/fixtures/bundle/assets/web/custom.css new file mode 100644 index 0000000000..992b81c80e --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/web/custom.css @@ -0,0 +1,2 @@ +/* Dummy CSS for bundle testing */ +body { color: red; } diff --git a/tests/unit_tests/fixtures/bundle/assets/web/custom.js b/tests/unit_tests/fixtures/bundle/assets/web/custom.js new file mode 100644 index 0000000000..9be8a6b2dc --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/assets/web/custom.js @@ -0,0 +1,2 @@ +// Dummy JS for bundle testing +console.log("test"); diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml new file mode 100644 index 0000000000..f834a8d867 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -0,0 +1,60 @@ +esphome: + name: bundle-test + includes: + - includes/custom_sensor.h + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + <<: !include common/base.yaml + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + +ota: + - platform: esphome + password: !secret ota_password + +web_server: + port: 80 + css_include: assets/web/custom.css + js_include: assets/web/custom.js + +i2c: + sda: GPIO21 + scl: GPIO22 + +font: + - id: test_font + file: assets/fonts/test_font.ttf + size: 16 + +image: + - id: test_image + file: assets/images/logo.png + type: BINARY + resize: 16x16 + +animation: + - id: test_animation + file: assets/images/animation.gif + type: BINARY + resize: 16x16 + +display: + - platform: ssd1306_i2c + model: SSD1306_128X64 + address: 0x3C + lambda: |- + it.image(0, 0, id(test_image)); + +external_components: + - source: + type: local + path: local_components diff --git a/tests/unit_tests/fixtures/bundle/common/base.yaml b/tests/unit_tests/fixtures/bundle/common/base.yaml new file mode 100644 index 0000000000..58e1083e82 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/base.yaml @@ -0,0 +1 @@ +level: DEBUG diff --git a/tests/unit_tests/fixtures/bundle/includes/custom_sensor.h b/tests/unit_tests/fixtures/bundle/includes/custom_sensor.h new file mode 100644 index 0000000000..7f0ff474ee --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/includes/custom_sensor.h @@ -0,0 +1,3 @@ +// Dummy custom sensor header for bundle testing +#pragma once +#include "esphome/core/component.h" diff --git a/tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py b/tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py new file mode 100644 index 0000000000..aa9fc1474b --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/local_components/my_component/__init__.py @@ -0,0 +1 @@ +# Dummy local external component for bundle testing diff --git a/tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h b/tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h new file mode 100644 index 0000000000..19b89ecc82 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/local_components/my_component/my_component.h @@ -0,0 +1,2 @@ +// Dummy component header for bundle testing +#pragma once diff --git a/tests/unit_tests/fixtures/bundle/secrets.yaml b/tests/unit_tests/fixtures/bundle/secrets.yaml new file mode 100644 index 0000000000..47acddb4d9 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/secrets.yaml @@ -0,0 +1,4 @@ +wifi_ssid: "TestNetwork" +wifi_password: "TestPassword123" +api_key: "unused_secret_should_not_appear" +ota_password: "ota_test_password" diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 37779f23e6..a377cf185a 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1,14 +1,16 @@ """Tests for esphome.automation module.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, call, patch import pytest from esphome.automation import ( + CallbackAutomation, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + build_callback_automations, has_non_synchronous_actions, ) from esphome.cpp_generator import MockObj, RawExpression @@ -254,3 +256,222 @@ def test_trigger_forwarder_custom_type() -> None: custom = MockObj("MyForwarder", "") result = _build_forwarder("auto_1", [], forwarder=custom) assert result == "MyForwarder{auto_1}" + + +@pytest.fixture +def mock_build_callback() -> Generator[AsyncMock]: + """Patch build_callback_automation to capture calls.""" + with patch( + "esphome.automation.build_callback_automation", new_callable=AsyncMock + ) as mock: + yield mock + + +@pytest.mark.asyncio +async def test_build_callback_automations_empty_entries( + mock_build_callback: AsyncMock, +) -> None: + """No entries means no calls.""" + parent = MockObj("var", "->") + await build_callback_automations(parent, {}, ()) + mock_build_callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_build_callback_automations_missing_config_key( + mock_build_callback: AsyncMock, +) -> None: + """Entry present but config key missing -- no calls.""" + parent = MockObj("var", "->") + await build_callback_automations( + parent, + {}, + (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), + ) + mock_build_callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_build_callback_automations_single_entry( + mock_build_callback: AsyncMock, +) -> None: + """Single entry with one config triggers one call.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_state": [conf]} + await build_callback_automations( + parent, + config, + (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_state_callback", [(bool, "x")], conf, forwarder=None + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_multiple_configs( + mock_build_callback: AsyncMock, +) -> None: + """Single entry with multiple configs triggers multiple calls.""" + parent = MockObj("var", "->") + conf1: dict[str, object] = {"automation_id": "auto_1", "then": []} + conf2: dict[str, object] = {"automation_id": "auto_2", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_state": [conf1, conf2]} + await build_callback_automations( + parent, + config, + (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), + ) + assert mock_build_callback.call_count == 2 + mock_build_callback.assert_any_call( + parent, "add_on_state_callback", [(bool, "x")], conf1, forwarder=None + ) + mock_build_callback.assert_any_call( + parent, "add_on_state_callback", [(bool, "x")], conf2, forwarder=None + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_multiple_entries( + mock_build_callback: AsyncMock, +) -> None: + """Multiple entries each with one config.""" + parent = MockObj("var", "->") + conf_a: dict[str, object] = {"automation_id": "auto_a", "then": []} + conf_b: dict[str, object] = {"automation_id": "auto_b", "then": []} + config: dict[str, list[dict[str, object]]] = { + "on_value": [conf_a], + "on_raw_value": [conf_b], + } + await build_callback_automations( + parent, + config, + ( + CallbackAutomation("on_value", "add_on_value_callback", [(float, "x")]), + CallbackAutomation( + "on_raw_value", "add_on_raw_value_callback", [(float, "x")] + ), + ), + ) + assert mock_build_callback.call_count == 2 + assert mock_build_callback.call_args_list == [ + call(parent, "add_on_value_callback", [(float, "x")], conf_a, forwarder=None), + call( + parent, "add_on_raw_value_callback", [(float, "x")], conf_b, forwarder=None + ), + ] + + +@pytest.mark.asyncio +async def test_build_callback_automations_with_forwarder( + mock_build_callback: AsyncMock, +) -> None: + """Entry with forwarder passes it through.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_press": [conf]} + await build_callback_automations( + parent, + config, + ( + CallbackAutomation( + "on_press", "add_on_state_callback", forwarder=TriggerOnTrueForwarder + ), + ), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_mixed_entries( + mock_build_callback: AsyncMock, +) -> None: + """Mix of entries with args, forwarders, and defaults.""" + parent = MockObj("var", "->") + conf_state: dict[str, object] = {"automation_id": "auto_1", "then": []} + conf_press: dict[str, object] = {"automation_id": "auto_2", "then": []} + conf_release: dict[str, object] = {"automation_id": "auto_3", "then": []} + config: dict[str, list[dict[str, object]]] = { + "on_state": [conf_state], + "on_press": [conf_press], + "on_release": [conf_release], + } + await build_callback_automations( + parent, + config, + ( + CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]), + CallbackAutomation( + "on_press", "add_on_state_callback", forwarder=TriggerOnTrueForwarder + ), + CallbackAutomation( + "on_release", "add_on_state_callback", forwarder=TriggerOnFalseForwarder + ), + ), + ) + assert mock_build_callback.call_count == 3 + assert mock_build_callback.call_args_list == [ + call( + parent, "add_on_state_callback", [(bool, "x")], conf_state, forwarder=None + ), + call( + parent, + "add_on_state_callback", + [], + conf_press, + forwarder=TriggerOnTrueForwarder, + ), + call( + parent, + "add_on_state_callback", + [], + conf_release, + forwarder=TriggerOnFalseForwarder, + ), + ] + + +@pytest.mark.asyncio +async def test_build_callback_automations_skips_missing_keys( + mock_build_callback: AsyncMock, +) -> None: + """Entries whose config keys are absent are silently skipped.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_press": [conf]} + await build_callback_automations( + parent, + config, + ( + CallbackAutomation( + "on_press", "add_on_state_callback", forwarder=TriggerOnTrueForwarder + ), + CallbackAutomation( + "on_release", "add_on_state_callback", forwarder=TriggerOnFalseForwarder + ), + ), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + ) + + +@pytest.mark.asyncio +async def test_build_callback_automations_defaults( + mock_build_callback: AsyncMock, +) -> None: + """Verify CallbackAutomation with only required fields defaults args=[] and forwarder=None.""" + parent = MockObj("var", "->") + conf: dict[str, object] = {"automation_id": "auto_1", "then": []} + config: dict[str, list[dict[str, object]]] = {"on_press": [conf]} + await build_callback_automations( + parent, + config, + (CallbackAutomation("on_press", "add_on_press_callback"),), + ) + mock_build_callback.assert_called_once_with( + parent, "add_on_press_callback", [], conf, forwarder=None + ) diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py new file mode 100644 index 0000000000..b8b2d0ffd1 --- /dev/null +++ b/tests/unit_tests/test_bundle.py @@ -0,0 +1,1210 @@ +"""Tests for esphome.bundle module.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +import tarfile +from typing import Any + +import pytest + +from esphome.bundle import ( + BUNDLE_EXTENSION, + CURRENT_MANIFEST_VERSION, + MANIFEST_FILENAME, + BundleManifest, + ConfigBundleCreator, + ManifestKey, + _add_bytes_to_tar, + _default_target_dir, + _find_used_secret_keys, + extract_bundle, + is_bundle_path, + prepare_bundle_for_compile, + read_bundle_manifest, +) +from esphome.core import CORE, EsphomeError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_bundle( + tmp_path: Path, + config_filename: str = "test.yaml", + config_content: str = "esphome:\n name: test\n", + manifest_overrides: dict[str, Any] | None = None, + extra_files: dict[str, bytes] | None = None, + *, + include_manifest: bool = True, + raw_members: list[tarfile.TarInfo] | None = None, +) -> Path: + """Create a minimal bundle tar.gz for testing.""" + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + if include_manifest: + manifest: dict[str, Any] = { + ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, + ManifestKey.ESPHOME_VERSION: "2026.2.0-test", + ManifestKey.CONFIG_FILENAME: config_filename, + ManifestKey.FILES: [config_filename], + ManifestKey.HAS_SECRETS: False, + } + if manifest_overrides: + manifest.update(manifest_overrides) + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + + _add_bytes_to_tar(tar, config_filename, config_content.encode()) + + if extra_files: + for name, data in extra_files.items(): + _add_bytes_to_tar(tar, name, data) + + if raw_members: + for info in raw_members: + tar.addfile(info, io.BytesIO(b"")) + + bundle_path.write_bytes(buf.getvalue()) + return bundle_path + + +def _setup_config_dir( + tmp_path: Path, + files: dict[str, str] | None = None, +) -> Path: + """Set up a fake config directory with files and configure CORE.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + config_yaml = "esphome:\n name: test\n" + (config_dir / "test.yaml").write_text(config_yaml) + + if files: + for rel_path, content in files.items(): + p = config_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + + CORE.config_path = config_dir / "test.yaml" + return config_dir + + +# --------------------------------------------------------------------------- +# is_bundle_path +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + (f"my_device{BUNDLE_EXTENSION}", True), + (f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True), + ("my_device.yaml", False), + ("my_device.tar.gz", False), + ("my_device.zip", False), + ("", False), + ], +) +def test_is_bundle_path(filename: str, expected: bool) -> None: + assert is_bundle_path(Path(filename)) is expected + + +# --------------------------------------------------------------------------- +# _default_target_dir +# --------------------------------------------------------------------------- + + +def test_default_target_dir_strips_extension() -> None: + p = Path(f"/builds/device{BUNDLE_EXTENSION}") + result = _default_target_dir(p) + assert result == Path("/builds/device") + + +def test_default_target_dir_no_extension() -> None: + p = Path("/builds/device.other") + result = _default_target_dir(p) + assert result == Path("/builds/device.other") + + +# --------------------------------------------------------------------------- +# _find_used_secret_keys +# --------------------------------------------------------------------------- + + +def test_find_used_secret_keys(tmp_path: Path) -> None: + yaml1 = tmp_path / "a.yaml" + yaml1.write_text("wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_pw\n") + yaml2 = tmp_path / "b.yaml" + yaml2.write_text("api:\n key: !secret api_key\n") + + keys = _find_used_secret_keys([yaml1, yaml2]) + assert keys == {"wifi_ssid", "wifi_pw", "api_key"} + + +def test_find_used_secret_keys_no_secrets(tmp_path: Path) -> None: + yaml1 = tmp_path / "a.yaml" + yaml1.write_text("esphome:\n name: test\n") + + keys = _find_used_secret_keys([yaml1]) + assert keys == set() + + +def test_find_used_secret_keys_missing_file(tmp_path: Path) -> None: + missing = tmp_path / "does_not_exist.yaml" + keys = _find_used_secret_keys([missing]) + assert keys == set() + + +def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: + yaml1 = tmp_path / "a.yaml" + yaml1.write_text("a: !secret key1\nb: !secret key1\n") + + keys = _find_used_secret_keys([yaml1]) + assert keys == {"key1"} + + +# --------------------------------------------------------------------------- +# _add_bytes_to_tar +# --------------------------------------------------------------------------- + + +def test_add_bytes_to_tar_deterministic_metadata() -> None: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + _add_bytes_to_tar(tar, "hello.txt", b"world") + + buf.seek(0) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + member = tar.getmember("hello.txt") + assert member.size == 5 + assert member.mtime == 0 + assert member.uid == 0 + assert member.gid == 0 + assert member.mode == 0o644 + assert tar.extractfile(member).read() == b"world" + + +# --------------------------------------------------------------------------- +# ManifestKey +# --------------------------------------------------------------------------- + + +def test_manifest_key_values() -> None: + assert ManifestKey.MANIFEST_VERSION == "manifest_version" + assert ManifestKey.ESPHOME_VERSION == "esphome_version" + assert ManifestKey.CONFIG_FILENAME == "config_filename" + assert ManifestKey.FILES == "files" + assert ManifestKey.HAS_SECRETS == "has_secrets" + + +def test_manifest_key_is_str() -> None: + """Verify ManifestKey values work as dict keys and JSON keys.""" + d: dict[str, int] = {ManifestKey.MANIFEST_VERSION: 1} + assert d["manifest_version"] == 1 + + +# --------------------------------------------------------------------------- +# extract_bundle +# --------------------------------------------------------------------------- + + +def test_extract_bundle_basic(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + target = tmp_path / "output" + + config_path = extract_bundle(bundle_path, target) + + assert config_path.is_file() + assert config_path.name == "test.yaml" + assert config_path.read_text().startswith("esphome:") + assert (target / MANIFEST_FILENAME).is_file() + + +def test_extract_bundle_default_target_dir(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + + config_path = extract_bundle(bundle_path) + + expected_dir = tmp_path / "device" + assert config_path.parent == expected_dir + + +def test_extract_bundle_missing_file(tmp_path: Path) -> None: + missing = tmp_path / f"missing{BUNDLE_EXTENSION}" + with pytest.raises(EsphomeError, match="Bundle file not found"): + extract_bundle(missing) + + +def test_extract_bundle_missing_manifest(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path, include_manifest=False) + with pytest.raises(EsphomeError, match="missing manifest.json"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_future_manifest_version(tmp_path: Path) -> None: + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.MANIFEST_VERSION: 999}, + ) + with pytest.raises(EsphomeError, match="newer than this ESPHome"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_missing_config_filename_in_manifest(tmp_path: Path) -> None: + """Manifest exists but is missing config_filename key.""" + bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + manifest = {ManifestKey.MANIFEST_VERSION: 1} + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="missing 'config_filename'"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_config_not_in_archive(tmp_path: Path) -> None: + """Manifest references a config file that isn't in the archive.""" + bundle_path = _make_bundle( + tmp_path, + config_filename="test.yaml", + manifest_overrides={ManifestKey.CONFIG_FILENAME: "missing.yaml"}, + ) + with pytest.raises(EsphomeError, match="was not found in the archive"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_with_extra_files(tmp_path: Path) -> None: + bundle_path = _make_bundle( + tmp_path, + extra_files={ + "common/base.yaml": b"level: DEBUG\n", + "includes/sensor.h": b"#pragma once\n", + }, + ) + target = tmp_path / "out" + extract_bundle(bundle_path, target) + + assert (target / "common" / "base.yaml").read_text() == "level: DEBUG\n" + assert (target / "includes" / "sensor.h").read_text() == "#pragma once\n" + + +# --------------------------------------------------------------------------- +# extract_bundle - security validation +# --------------------------------------------------------------------------- + + +def test_extract_bundle_rejects_absolute_path(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="/etc/passwd") + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="absolute path"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_path_traversal(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="../../../etc/passwd") + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="path traversal"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_backslash_path_traversal(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="foo\\..\\..\\etc\\passwd") + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="path traversal"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_symlink(tmp_path: Path) -> None: + info = tarfile.TarInfo(name="evil_link") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + info.size = 0 + bundle_path = _make_bundle(tmp_path, raw_members=[info]) + + with pytest.raises(EsphomeError, match="symlink"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_rejects_oversized( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Archive whose total decompressed size exceeds the limit is rejected.""" + # Lower the limit so we don't need huge test data + monkeypatch.setattr("esphome.bundle.MAX_DECOMPRESSED_SIZE", 100) + + bundle_path = _make_bundle( + tmp_path, + extra_files={"big.bin": b"\x00" * 200}, + ) + + with pytest.raises(EsphomeError, match="decompressed size exceeds"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_corrupted_tar(tmp_path: Path) -> None: + """Corrupted tar file raises EsphomeError.""" + bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}" + bundle_path.write_bytes(b"not a tar file at all") + + with pytest.raises(EsphomeError, match="Failed to extract bundle"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_malformed_manifest_json(tmp_path: Path) -> None: + """Invalid JSON in manifest.json raises EsphomeError.""" + bundle_path = tmp_path / f"badjson{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + _add_bytes_to_tar(tar, MANIFEST_FILENAME, b"{invalid json") + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="malformed manifest.json"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_missing_manifest_version(tmp_path: Path) -> None: + """Manifest without manifest_version raises EsphomeError.""" + bundle_path = tmp_path / f"nover{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + manifest = {ManifestKey.CONFIG_FILENAME: "test.yaml"} + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="missing 'manifest_version'"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_invalid_manifest_version_type(tmp_path: Path) -> None: + """Non-integer manifest_version raises EsphomeError.""" + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.MANIFEST_VERSION: "not_an_int"}, + ) + + with pytest.raises(EsphomeError, match="must be a positive integer"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_manifest_version_zero(tmp_path: Path) -> None: + """manifest_version of 0 is rejected.""" + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.MANIFEST_VERSION: 0}, + ) + + with pytest.raises(EsphomeError, match="must be a positive integer"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_manifest_too_large( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Oversized manifest.json is rejected.""" + monkeypatch.setattr("esphome.bundle.MAX_MANIFEST_SIZE", 50) + + bundle_path = _make_bundle(tmp_path) + + with pytest.raises(EsphomeError, match="manifest.json too large"): + extract_bundle(bundle_path, tmp_path / "out") + + +def test_extract_bundle_manifest_not_regular_file(tmp_path: Path) -> None: + """manifest.json that is a directory entry raises EsphomeError.""" + bundle_path = tmp_path / f"dirmanifest{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + # Add manifest.json as a directory instead of a file + dir_info = tarfile.TarInfo(name=MANIFEST_FILENAME) + dir_info.type = tarfile.DIRTYPE + dir_info.size = 0 + tar.addfile(dir_info) + _add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n") + bundle_path.write_bytes(buf.getvalue()) + + with pytest.raises(EsphomeError, match="not a regular file"): + extract_bundle(bundle_path, tmp_path / "out") + + +# --------------------------------------------------------------------------- +# read_bundle_manifest +# --------------------------------------------------------------------------- + + +def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None: + """Corrupted tar file raises EsphomeError via read_bundle_manifest.""" + bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}" + bundle_path.write_bytes(b"not a tar file") + + with pytest.raises(EsphomeError, match="Failed to read bundle"): + read_bundle_manifest(bundle_path) + + +def test_read_bundle_manifest(tmp_path: Path) -> None: + bundle_path = _make_bundle( + tmp_path, + manifest_overrides={ManifestKey.HAS_SECRETS: True}, + extra_files={"secrets.yaml": b"wifi: test\n"}, + ) + + manifest = read_bundle_manifest(bundle_path) + + assert isinstance(manifest, BundleManifest) + assert manifest.manifest_version == CURRENT_MANIFEST_VERSION + assert manifest.esphome_version == "2026.2.0-test" + assert manifest.config_filename == "test.yaml" + assert manifest.has_secrets is True + + +def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: + """Manifest with only required fields.""" + bundle_path = tmp_path / f"min{BUNDLE_EXTENSION}" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + manifest = { + ManifestKey.MANIFEST_VERSION: 1, + ManifestKey.CONFIG_FILENAME: "cfg.yaml", + } + _add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode()) + _add_bytes_to_tar(tar, "cfg.yaml", b"") + bundle_path.write_bytes(buf.getvalue()) + + result = read_bundle_manifest(bundle_path) + assert result.esphome_version == "unknown" + assert result.files == [] + assert result.has_secrets is False + + +# --------------------------------------------------------------------------- +# prepare_bundle_for_compile +# --------------------------------------------------------------------------- + + +def test_prepare_bundle_preserves_build_cache(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + target = tmp_path / "work" + target.mkdir() + + # Pre-existing build cache + esphome_dir = target / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "build_state.json").write_text('{"cached": true}') + + pio_dir = target / ".pioenvs" + pio_dir.mkdir() + (pio_dir / "firmware.bin").write_bytes(b"\x00" * 100) + + config_path = prepare_bundle_for_compile(bundle_path, target) + + assert config_path.is_file() + # Build caches should be preserved + assert (target / ".esphome" / "build_state.json").read_text() == '{"cached": true}' + assert (target / ".pioenvs" / "firmware.bin").read_bytes() == b"\x00" * 100 + + +def test_prepare_bundle_cleans_old_config(tmp_path: Path) -> None: + bundle_path = _make_bundle(tmp_path) + target = tmp_path / "work" + target.mkdir() + + # Old config from previous extraction + (target / "old_config.yaml").write_text("old: true") + old_dir = target / "old_includes" + old_dir.mkdir() + (old_dir / "old.h").write_text("// old") + + prepare_bundle_for_compile(bundle_path, target) + + # Old files should be cleaned + assert not (target / "old_config.yaml").exists() + assert not (target / "old_includes").exists() + # New config should exist + assert (target / "test.yaml").is_file() + + +def test_prepare_bundle_missing_file(tmp_path: Path) -> None: + missing = tmp_path / f"missing{BUNDLE_EXTENSION}" + with pytest.raises(EsphomeError, match="Bundle file not found"): + prepare_bundle_for_compile(missing) + + +def test_prepare_bundle_cache_wins_over_bundle_content(tmp_path: Path) -> None: + """Pre-existing build cache is restored even if the bundle contains those dirs.""" + bundle_path = _make_bundle( + tmp_path, + extra_files={ + ".esphome/from_bundle.json": b'{"from": "bundle"}', + }, + ) + target = tmp_path / "work" + target.mkdir() + + # Pre-existing build cache + esphome_dir = target / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "local_cache.json").write_text('{"from": "local"}') + + prepare_bundle_for_compile(bundle_path, target) + + # Local cache should win over bundle content + assert (target / ".esphome" / "local_cache.json").read_text() == '{"from": "local"}' + assert not (target / ".esphome" / "from_bundle.json").exists() + + +def test_prepare_bundle_default_target_dir(tmp_path: Path) -> None: + """prepare_bundle_for_compile uses default dir when target_dir is None.""" + bundle_path = _make_bundle(tmp_path) + + config_path = prepare_bundle_for_compile(bundle_path) + + expected_dir = tmp_path / "device" + assert config_path.parent == expected_dir + assert config_path.is_file() + + +# --------------------------------------------------------------------------- +# ConfigBundleCreator - file discovery +# --------------------------------------------------------------------------- + + +def test_discover_files_includes_config(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_finds_path_objects(tmp_path: Path) -> None: + """Path objects in validated config are discovered.""" + config_dir = _setup_config_dir( + tmp_path, + files={"assets/font.ttf": "fake font data"}, + ) + + config: dict[str, Any] = {"font": [{"file": config_dir / "assets" / "font.ttf"}]} + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "assets/font.ttf" in paths + + +def test_discover_files_finds_absolute_string_paths(tmp_path: Path) -> None: + """Absolute string paths in validated config are discovered.""" + config_dir = _setup_config_dir( + tmp_path, + files={"assets/logo.png": "fake png data"}, + ) + + abs_path = str(config_dir / "assets" / "logo.png") + config: dict[str, Any] = {"image": [{"file": abs_path}]} + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "assets/logo.png" in paths + + +def test_discover_files_skips_non_path_prefixes(tmp_path: Path) -> None: + """Remote URLs and special prefixes are not treated as file paths.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "font": [ + {"file": "https://example.com/font.ttf"}, + {"file": "mdi:home"}, + {"file": "http://example.com/icon.png"}, + ] + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + # Only the config file itself + assert len(files) == 1 + assert files[0].path == "test.yaml" + + +def test_discover_files_skips_multiline_strings(tmp_path: Path) -> None: + """Lambda/template strings are not treated as file paths.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "sensor": [{"lambda": "auto val = id(sensor1);\nreturn val;"}] + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_deduplicates(tmp_path: Path) -> None: + """Same file referenced twice is only included once.""" + config_dir = _setup_config_dir( + tmp_path, + files={"cert.pem": "fake cert"}, + ) + + abs_path = str(config_dir / "cert.pem") + config: dict[str, Any] = { + "a": {"cert": abs_path}, + "b": {"cert": abs_path}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + cert_files = [f for f in files if f.path == "cert.pem"] + assert len(cert_files) == 1 + + +def test_discover_files_skips_outside_config_dir(tmp_path: Path) -> None: + """Files outside the config directory are skipped.""" + _setup_config_dir(tmp_path) + + outside_file = tmp_path / "outside.pem" + outside_file.write_text("outside cert") + + config: dict[str, Any] = {"cert": str(outside_file)} + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "outside.pem" not in paths + + +def test_discover_files_esphome_includes(tmp_path: Path) -> None: + """Paths listed in esphome.includes are discovered.""" + _setup_config_dir( + tmp_path, + files={"my_sensor.h": "#pragma once\n"}, + ) + + config: dict[str, Any] = { + "esphome": {"includes": ["my_sensor.h"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_sensor.h" in paths + + +def test_discover_files_esphome_includes_directory(tmp_path: Path) -> None: + """esphome.includes pointing to a directory adds all files.""" + _setup_config_dir( + tmp_path, + files={ + "my_lib/a.h": "// a", + "my_lib/b.cpp": "// b", + }, + ) + + config: dict[str, Any] = { + "esphome": {"includes": ["my_lib"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_lib/a.h" in paths + assert "my_lib/b.cpp" in paths + + +def test_discover_files_esphome_includes_skips_system(tmp_path: Path) -> None: + """System includes like are not added.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "esphome": {"includes": [""]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert len(paths) == 1 # Just test.yaml + + +def test_discover_files_external_components_local(tmp_path: Path) -> None: + """external_components with type: local adds the directory.""" + _setup_config_dir( + tmp_path, + files={ + "components/my_comp/__init__.py": "# comp", + "components/my_comp/sensor.py": "# sensor", + }, + ) + + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local", "path": "components"}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "components/my_comp/__init__.py" in paths + assert "components/my_comp/sensor.py" in paths + + +def test_discover_files_external_components_skips_pycache(tmp_path: Path) -> None: + """__pycache__ directories inside local external_components are excluded.""" + _setup_config_dir( + tmp_path, + files={ + "components/my_comp/__init__.py": "# comp", + "components/my_comp/__pycache__/module.cpython-313.pyc": "bytecode", + }, + ) + + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local", "path": "components"}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "components/my_comp/__init__.py" in paths + assert not any("__pycache__" in p for p in paths) + + +def test_discover_files_external_components_non_dict_source(tmp_path: Path) -> None: + """external_components with string source (e.g. github shorthand) is skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [{"source": "github://user/repo@main"}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + # Only the config file itself - no crash from non-dict source + assert len(files) == 1 + assert files[0].path == "test.yaml" + + +def test_discover_files_nested_config_values(tmp_path: Path) -> None: + """Deeply nested Path objects in lists/dicts are found.""" + config_dir = _setup_config_dir( + tmp_path, + files={"deep/file.pem": "cert data"}, + ) + + config: dict[str, Any] = { + "level1": {"level2": [{"level3": config_dir / "deep" / "file.pem"}]} + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "deep/file.pem" in paths + + +def test_discover_files_idempotent_secrets(tmp_path: Path) -> None: + """Calling discover_files twice does not accumulate secrets paths.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("k: v\n") + (config_dir / "test.yaml").write_text("a: !secret k\n") + + creator = ConfigBundleCreator({}) + files1 = creator.discover_files() + files2 = creator.discover_files() + + # Both calls should return the same result (secrets not accumulated) + paths1 = [f.path for f in files1] + paths2 = [f.path for f in files2] + assert "secrets.yaml" in paths1 + assert paths1 == paths2 + + +def test_discover_files_skips_missing_file(tmp_path: Path) -> None: + """_add_file logs warning for non-existent files via includes.""" + _setup_config_dir(tmp_path) + + # Include references a file that doesn't exist on disk + config: dict[str, Any] = { + "esphome": {"includes": ["nonexistent.h"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "nonexistent.h" not in paths + + +def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: + """_add_directory logs warning for non-existent directories.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [ + {"source": {"type": "local", "path": "nonexistent_dir"}} + ], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + # Only the config file + assert len(files) == 1 + + +def test_discover_files_yaml_reload_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """YAML reload failure during include discovery is handled gracefully.""" + _setup_config_dir(tmp_path) + + def _raise_error(*args, **kwargs): + raise EsphomeError("parse error") + + monkeypatch.setattr("esphome.yaml_util.load_yaml", _raise_error) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + # Should still have the config file at minimum + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_esphome_includes_c(tmp_path: Path) -> None: + """Paths listed in esphome.includes_c are discovered.""" + _setup_config_dir( + tmp_path, + files={"my_code.c": "// c code"}, + ) + + config: dict[str, Any] = { + "esphome": {"includes_c": ["my_code.c"]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_code.c" in paths + + +def test_discover_files_external_components_non_local_type(tmp_path: Path) -> None: + """external_components with type != 'local' are skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [ + {"source": {"type": "git", "url": "https://github.com/user/repo"}} + ], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_external_components_no_path(tmp_path: Path) -> None: + """external_components with local type but missing path are skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local"}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_external_components_absolute_path(tmp_path: Path) -> None: + """external_components with absolute path are resolved correctly.""" + config_dir = _setup_config_dir( + tmp_path, + files={"ext/comp/__init__.py": "# comp"}, + ) + + abs_path = str(config_dir / "ext") + config: dict[str, Any] = { + "external_components": [{"source": {"type": "local", "path": abs_path}}], + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "ext/comp/__init__.py" in paths + + +def test_discover_files_relative_string_with_known_extension(tmp_path: Path) -> None: + """Relative strings with known extensions are resolved and warned.""" + _setup_config_dir( + tmp_path, + files={"my_cert.pem": "cert data"}, + ) + + config: dict[str, Any] = { + "component": {"cert": "my_cert.pem"}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_cert.pem" in paths + + +def test_discover_files_relative_string_missing_file(tmp_path: Path) -> None: + """Relative strings with known extensions that don't exist are skipped.""" + _setup_config_dir(tmp_path) + + config: dict[str, Any] = { + "component": {"cert": "nonexistent.pem"}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + assert len(files) == 1 + + +def test_discover_files_esphome_includes_absolute_path(tmp_path: Path) -> None: + """esphome.includes with absolute path is handled.""" + config_dir = _setup_config_dir( + tmp_path, + files={"my_code.h": "#pragma once"}, + ) + + config: dict[str, Any] = { + "esphome": {"includes": [str(config_dir / "my_code.h")]}, + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "my_code.h" in paths + + +def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: + """Tuples in config are walked like lists.""" + config_dir = _setup_config_dir( + tmp_path, + files={"a.pem": "cert"}, + ) + + config: dict[str, Any] = { + "items": (config_dir / "a.pem",), + } + creator = ConfigBundleCreator(config) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "a.pem" in paths + + +# --------------------------------------------------------------------------- +# ConfigBundleCreator - create_bundle +# --------------------------------------------------------------------------- + + +def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert isinstance(result.data, bytes) + assert len(result.data) > 0 + + # Verify it's a valid tar.gz + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + names = tar.getnames() + assert MANIFEST_FILENAME in names + assert "test.yaml" in names + + +def test_create_bundle_manifest_content(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + manifest = result.manifest + assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION + assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml" + assert "test.yaml" in manifest[ManifestKey.FILES] + + +def test_create_bundle_filters_secrets(tmp_path: Path) -> None: + config_dir = _setup_config_dir(tmp_path) + + # Create secrets.yaml with multiple secrets + secrets = config_dir / "secrets.yaml" + secrets.write_text( + "wifi_ssid: MyNetwork\nwifi_pw: secret123\nunused: should_not_appear\n" + ) + + # Config that references only some secrets + config_yaml = "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_pw\n" + (config_dir / "test.yaml").write_text(config_yaml) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + # Extract and check secrets + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + + assert "wifi_ssid" in secrets_data + assert "wifi_pw" in secrets_data + assert "unused" not in secrets_data + assert "should_not_appear" not in secrets_data + + +def test_create_bundle_no_secrets(tmp_path: Path) -> None: + _setup_config_dir(tmp_path) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_secrets_load_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Secrets file that fails to load during filtering is skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("k: v\n") + (config_dir / "test.yaml").write_text("a: !secret k\n") + + from esphome import yaml_util as yu + + original_load = yu.load_yaml + + def _failing_on_filter(fname, *args, clear_secrets=True, **kwargs): + # Fail only when _build_filtered_secrets calls with clear_secrets=False + if not clear_secrets and "secrets" in str(fname): + raise EsphomeError("corrupt secrets") + return original_load(fname, *args, clear_secrets=clear_secrets, **kwargs) + + monkeypatch.setattr(yu, "load_yaml", _failing_on_filter) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + # Should succeed without secrets since the filtered load failed + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_secrets_non_dict(tmp_path: Path) -> None: + """Secrets file that parses to non-dict is skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("- item1\n- item2\n") + (config_dir / "test.yaml").write_text("a: !secret k\n") + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_secrets_no_matching_keys(tmp_path: Path) -> None: + """Secrets with no matching keys produces empty filtered result.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("other_key: value\n") + (config_dir / "test.yaml").write_text("a: !secret nonexistent\n") + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is False + + +def test_create_bundle_deterministic_order(tmp_path: Path) -> None: + """Files are added in sorted order for reproducibility.""" + _setup_config_dir( + tmp_path, + files={ + "z_last.h": "// z", + "a_first.h": "// a", + "m_middle.h": "// m", + }, + ) + + config: dict[str, Any] = { + "esphome": {"includes": ["z_last.h", "a_first.h", "m_middle.h"]}, + } + creator = ConfigBundleCreator(config) + result = creator.create_bundle() + + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + names = tar.getnames() + + # manifest.json is always first, then files in sorted order + assert names[0] == MANIFEST_FILENAME + file_names = [n for n in names if n != MANIFEST_FILENAME] + assert file_names == sorted(file_names) + + +# --------------------------------------------------------------------------- +# Round-trip: create then extract +# --------------------------------------------------------------------------- + + +def test_bundle_round_trip(tmp_path: Path) -> None: + """A bundle created by ConfigBundleCreator can be extracted.""" + _setup_config_dir( + tmp_path, + files={"include.h": "#pragma once\n"}, + ) + config: dict[str, Any] = {"esphome": {"includes": ["include.h"]}} + + creator = ConfigBundleCreator(config) + result = creator.create_bundle() + + bundle_path = tmp_path / f"roundtrip{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + + target = tmp_path / "extracted" + config_path = extract_bundle(bundle_path, target) + + assert config_path.is_file() + assert (target / "include.h").read_text() == "#pragma once\n" + + manifest = read_bundle_manifest(bundle_path) + assert manifest.config_filename == "test.yaml" + assert "include.h" in manifest.files + + +def test_bundle_round_trip_with_secrets(tmp_path: Path) -> None: + """Secrets survive round-trip with correct filtering.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("key1: val1\nkey2: val2\nunused: nope\n") + (config_dir / "test.yaml").write_text("a: !secret key1\nb: !secret key2\n") + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + bundle_path = tmp_path / f"secrets{BUNDLE_EXTENSION}" + bundle_path.write_bytes(result.data) + + target = tmp_path / "extracted" + extract_bundle(bundle_path, target) + + secrets_content = (target / "secrets.yaml").read_text() + assert "key1" in secrets_content + assert "key2" in secrets_content + assert "unused" not in secrets_content + + manifest = read_bundle_manifest(bundle_path) + assert manifest.has_secrets is True diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ce941b40dc..ac84ce7cc8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -616,3 +616,152 @@ def test_validate_entity_name__none_with_friendly_name() -> None: result = config_validation._validate_entity_name("None") assert result is None CORE.friendly_name = None # Reset + + +# --- percentage validators --- + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("100%", 1.0), + (0.0, 0.0), + (0.5, 0.5), + (1.0, 1.0), + ("0.0", 0.0), + ("0.5", 0.5), + ("1.0", 1.0), + ), +) +def test_percentage__valid(value: object, expected: float) -> None: + assert config_validation.percentage(value) == expected + + +@pytest.mark.parametrize( + "value", + ( + "150%", + "-10%", + "-0.1", + "1.1", + 2, + -1, + "foo", + None, + ), +) +def test_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.percentage(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("100%", 1.0), + ("-50%", -0.5), + ("-100%", -1.0), + (0.0, 0.0), + (0.5, 0.5), + (-0.5, -0.5), + (1.0, 1.0), + (-1.0, -1.0), + ), +) +def test_possibly_negative_percentage__valid(value: object, expected: float) -> None: + assert config_validation.possibly_negative_percentage(value) == expected + + +@pytest.mark.parametrize( + "value", + ( + "150%", + "-150%", + 2, + -2, + "foo", + None, + ), +) +def test_possibly_negative_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.possibly_negative_percentage(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("100%", 1.0), + ("150%", 1.5), + ("200%", 2.0), + (0.0, 0.0), + (0.5, 0.5), + (1.0, 1.0), + ), +) +def test_unbounded_percentage__valid(value: object, expected: float) -> None: + assert config_validation.unbounded_percentage(value) == expected + + +@pytest.mark.parametrize( + "value", + ( + "-10%", + "-0.5", + -1, + "foo", + None, + ), +) +def test_unbounded_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.unbounded_percentage(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("0%", 0.0), + ("50%", 0.5), + ("150%", 1.5), + ("-50%", -0.5), + ("-150%", -1.5), + ("200%", 2.0), + ("-200%", -2.0), + (0.0, 0.0), + (0.5, 0.5), + (-0.5, -0.5), + (1.0, 1.0), + (-1.0, -1.0), + ), +) +def test_unbounded_possibly_negative_percentage__valid( + value: object, expected: float +) -> None: + assert config_validation.unbounded_possibly_negative_percentage(value) == expected + + +@pytest.mark.parametrize("value", ("foo", None)) +def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None: + with pytest.raises(Invalid): + config_validation.unbounded_possibly_negative_percentage(value) + + +@pytest.mark.parametrize( + "value", + (50, -50, 2, -2), +) +def test_percentage_validators__raw_number_above_one_without_percent_sign( + value: object, +) -> None: + """Raw numeric values outside [-1, 1] must use a percent sign.""" + with pytest.raises(Invalid, match="percent sign"): + config_validation.unbounded_percentage(value) + with pytest.raises(Invalid, match="percent sign"): + config_validation.unbounded_possibly_negative_percentage(value) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 115ce38c93..85536d2f1c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, choose_upload_log_host, command_analyze_memory, + command_bundle, command_clean_all, command_rename, command_update_all, @@ -47,6 +48,7 @@ from esphome.__main__ import ( upload_using_picotool, upload_using_platformio, ) +from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, @@ -1101,6 +1103,8 @@ class MockArgs: name: str | None = None dashboard: bool = False reset: bool = False + list_only: bool = False + output: str | None = None def test_upload_program_serial_esp32( @@ -3765,6 +3769,198 @@ esp32: assert "secrets.yaml" not in summary_section +# --- command_bundle tests --- + + +def test_command_bundle_list_only( + tmp_path: Path, + capsys: CaptureFixture[str], +) -> None: + """Test command_bundle with --list-only prints files and returns 0.""" + mock_files = [ + BundleFile(path="device.yaml", source=tmp_path / "device.yaml"), + BundleFile(path="secrets.yaml", source=tmp_path / "secrets.yaml"), + BundleFile(path="common/base.yaml", source=tmp_path / "common" / "base.yaml"), + ] + + args = MockArgs(list_only=True) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.discover_files.return_value = mock_files + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + captured = capsys.readouterr() + # Files should be printed in sorted order + assert "common/base.yaml" in captured.out + assert "device.yaml" in captured.out + assert "secrets.yaml" in captured.out + + +def test_command_bundle_list_only_empty( + tmp_path: Path, + capsys: CaptureFixture[str], +) -> None: + """Test command_bundle --list-only with no files discovered.""" + args = MockArgs(list_only=True) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.discover_files.return_value = [] + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + + +def test_command_bundle_creates_archive(tmp_path: Path) -> None: + """Test command_bundle creates archive at default output path.""" + CORE.config_path = tmp_path / "mydevice.yaml" + + mock_result = BundleResult( + data=b"fake-tar-gz-data", + manifest={"manifest_version": 1}, + files=[BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml")], + ) + + args = MockArgs() + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + output_path = tmp_path / f"mydevice{BUNDLE_EXTENSION}" + assert output_path.exists() + assert output_path.read_bytes() == b"fake-tar-gz-data" + + +def test_command_bundle_custom_output(tmp_path: Path) -> None: + """Test command_bundle with -o custom output path.""" + custom_output = tmp_path / "output" / "custom.esphomebundle.tar.gz" + mock_result = BundleResult( + data=b"custom-output-data", + manifest={"manifest_version": 1}, + files=[BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml")], + ) + + args = MockArgs(output=str(custom_output)) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + assert custom_output.exists() + assert custom_output.read_bytes() == b"custom-output-data" + + +def test_command_bundle_creates_parent_dirs(tmp_path: Path) -> None: + """Test command_bundle creates parent directories for output path.""" + nested_output = tmp_path / "deep" / "nested" / "dir" / "out.tar.gz" + mock_result = BundleResult( + data=b"data", + manifest={"manifest_version": 1}, + files=[BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml")], + ) + + args = MockArgs(output=str(nested_output)) + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator): + result = command_bundle(args, config) + + assert result == 0 + assert nested_output.exists() + + +def test_command_bundle_logs_info( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_bundle logs bundle creation info.""" + CORE.config_path = tmp_path / "mydevice.yaml" + + mock_result = BundleResult( + data=b"x" * 2048, + manifest={"manifest_version": 1}, + files=[ + BundleFile(path="mydevice.yaml", source=tmp_path / "mydevice.yaml"), + BundleFile(path="secrets.yaml", source=tmp_path / "secrets.yaml"), + ], + ) + + args = MockArgs() + config: dict[str, Any] = {} + + mock_creator = MagicMock() + mock_creator.create_bundle.return_value = mock_result + + with ( + patch("esphome.bundle.ConfigBundleCreator", return_value=mock_creator), + caplog.at_level(logging.INFO), + ): + result = command_bundle(args, config) + + assert result == 0 + assert "Bundle created" in caplog.text + assert "2 files" in caplog.text + assert "2.0 KB" in caplog.text + + +def test_run_esphome_bundle_detection(tmp_path: Path) -> None: + """Test run_esphome detects .esphomebundle.tar.gz and extracts it.""" + bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}" + bundle_path.write_bytes(b"fake-bundle") + + extracted_yaml = tmp_path / "extracted" / "device.yaml" + + with ( + patch("esphome.bundle.is_bundle_path", return_value=True) as mock_is_bundle, + patch( + "esphome.bundle.prepare_bundle_for_compile", + return_value=extracted_yaml, + ) as mock_prepare, + patch("esphome.__main__.read_config", return_value=None), + ): + result = run_esphome(["esphome", "compile", str(bundle_path)]) + + mock_is_bundle.assert_called_once() + mock_prepare.assert_called_once_with(bundle_path) + # read_config returns None → exit code 2 + assert result == 2 + + +def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None: + """Test run_esphome does not extract for regular .yaml files.""" + yaml_file = tmp_path / "device.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + with ( + patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle, + patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare, + patch("esphome.__main__.read_config", return_value=None), + ): + result = run_esphome(["esphome", "compile", str(yaml_file)]) + + mock_is_bundle.assert_called_once() + mock_prepare.assert_not_called() + assert result == 2 + + def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: """Test reading XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 667b593819..0342d12540 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -323,6 +323,60 @@ def test_dump_sort_keys() -> None: assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:") +# --------------------------------------------------------------------------- +# track_yaml_loads +# --------------------------------------------------------------------------- + + +def test_track_yaml_loads_records_files(tmp_path: Path) -> None: + """track_yaml_loads records every file loaded inside the context.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + + with yaml_util.track_yaml_loads() as loaded: + yaml_util.load_yaml(yaml_file) + + assert len(loaded) == 1 + assert loaded[0] == yaml_file.resolve() + + +def test_track_yaml_loads_records_includes(tmp_path: Path) -> None: + """track_yaml_loads records nested !include files.""" + inc = tmp_path / "included.yaml" + inc.write_text("included_key: 42\n") + main = tmp_path / "main.yaml" + main.write_text("child: !include included.yaml\n") + + with yaml_util.track_yaml_loads() as loaded: + yaml_util.load_yaml(main) + + resolved = [p.name for p in loaded] + assert "main.yaml" in resolved + assert "included.yaml" in resolved + + +def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None: + """Files loaded outside the context are not recorded.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + + with yaml_util.track_yaml_loads() as loaded: + pass # load nothing inside + + yaml_util.load_yaml(yaml_file) + assert loaded == [] + + +def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: + """Listener is removed even if the body raises.""" + before = len(yaml_util._load_listeners) + + with pytest.raises(RuntimeError), yaml_util.track_yaml_loads(): + raise RuntimeError("boom") + + assert len(yaml_util._load_listeners) == before + + @pytest.mark.parametrize( "data", [