diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index fc63198019..fb9dadc6a0 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -235,19 +235,20 @@ async function detectDeprecatedComponents(github, context, changedFiles) { } } - // Get PR head to fetch files from the PR branch - const prNumber = context.payload.pull_request.number; + // Get base branch ref to check if deprecation already exists for the component + // This prevents flagging a PR that simply adds deprecation + const baseRef = context.payload.pull_request.base.ref; // Check each component's __init__.py for DEPRECATED_COMPONENT constant for (const component of components) { const initFile = `esphome/components/${component}/__init__.py`; try { - // Fetch file content from PR head using GitHub API + // Fetch file content from base branch using GitHub API const { data: fileData } = await github.rest.repos.getContent({ owner, repo, path: initFile, - ref: `refs/pull/${prNumber}/head` + ref: baseRef }); // Decode base64 content diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71703652e8..cf9fa8e7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 # v4 + uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 with: run: ${{ steps.build.outputs.binary }} mode: simulation @@ -723,7 +723,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash + SKIP: pylint,clang-tidy-hash,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() @@ -868,7 +868,8 @@ jobs: python script/test_build_components.py \ -e compile \ -c "$component_list" \ - -t "$platform" 2>&1 | \ + -t "$platform" \ + --base-only 2>&1 | \ tee /dev/stderr | \ python script/ci_memory_impact_extract.py \ --output-env \ @@ -954,7 +955,8 @@ jobs: python script/test_build_components.py \ -e compile \ -c "$component_list" \ - -t "$platform" 2>&1 | \ + -t "$platform" \ + --base-only 2>&1 | \ tee /dev/stderr | \ python script/ci_memory_impact_extract.py \ --output-env \ diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 2ad023ed1b..0021654def 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -3,6 +3,9 @@ name: PR Title Check on: pull_request: types: [opened, edited, synchronize, reopened] + branches-ignore: + - release + - beta permissions: contents: read 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/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index cca70815b9..6483bbe789 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -2,30 +2,29 @@ name: Status check labels on: pull_request: - types: [labeled, unlabeled] + types: [opened, reopened, labeled, unlabeled, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: check: - name: Check ${{ matrix.label }} + name: Check blocking labels runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - label: - - needs-docs - - merge-after-release - - chained-pr steps: - - name: Check for ${{ matrix.label }} label + - name: Check for blocking labels uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | + const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }); - const hasLabel = labels.find(label => label.name === '${{ matrix.label }}'); - if (hasLabel) { - core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}'); + const labelNames = labels.map(l => l.name); + const found = blockingLabels.filter(bl => labelNames.includes(bl)); + if (found.length > 0) { + core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`); } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ff1685ea7..f8c21aad36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,3 +65,7 @@ repos: files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ pass_filenames: false additional_dependencies: [] + - id: ci-custom + name: ci-custom + entry: python3 script/run-in-env.py script/ci-custom.py + language: system diff --git a/CODEOWNERS b/CODEOWNERS index fffe5ce91c..5b1ae65f1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -142,12 +142,13 @@ esphome/components/dlms_meter/* @SimonFischer04 esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its -esphome/components/dsmr/* @glmnet @PolarGoose @zuidwijk +esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov 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/Doxyfile b/Doxyfile index cfdb74bd19..7fce941c9b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.4.0-dev +PROJECT_NUMBER = 2026.5.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/__main__.py b/esphome/__main__.py index 87abd7f796..25b404ae45 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1083,7 +1083,7 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: # add the console decoration so the front-end can hide the secrets if not args.show_secrets: output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[5m\2\\033[6m", output + r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output ) if not CORE.quiet: safe_print(output) @@ -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/codegen.py b/esphome/codegen.py index 30e3135360..a5b5abe447 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -79,6 +79,7 @@ from esphome.cpp_types import ( # noqa: F401 float_, global_ns, gpio_Flags, + int8, int16, int32, int64, diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 821e4a3105..2dfab8ff85 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -8,6 +8,9 @@ namespace ade7953_base { static const char *const TAG = "ade7953"; +constexpr uint16_t CONFIG_DEFAULT = 0x8004u; +constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u; + static const float ADE_POWER_FACTOR = 154.0f; static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600; @@ -18,7 +21,12 @@ void ADE7953::setup() { // The chip might take up to 100ms to initialise this->set_timeout(100, [this]() { - // this->ade_write_8(0x0010, 0x04); + // Lock communication interface (SPI or I2C) + uint16_t config_v = CONFIG_DEFAULT; + this->ade_read_16(CONFIG_16, &config_v); + config_v &= static_cast(~CONFIG_LOCK_BIT); // Clear the lock bit + this->ade_write_16(CONFIG_16, config_v); + // Configure optimum settings according to datasheet this->ade_write_8(0x00FE, 0xAD); this->ade_write_16(0x0120, 0x0030); // Set gains diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h index bcafddca4e..b58f95b230 100644 --- a/esphome/components/ade7953_base/ade7953_base.h +++ b/esphome/components/ade7953_base/ade7953_base.h @@ -9,31 +9,35 @@ namespace esphome { namespace ade7953_base { -static const uint8_t PGA_V_8 = +static constexpr uint8_t PGA_V_8 = 0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0]) -static const uint8_t PGA_IA_8 = +static constexpr uint8_t PGA_IA_8 = 0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0]) -static const uint8_t PGA_IB_8 = +static constexpr uint8_t PGA_IB_8 = 0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0]) -static const uint32_t AIGAIN_32 = +static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register + +static constexpr uint16_t AIGAIN_32 = 0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit) -static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) -static const uint32_t AWGAIN_32 = +static constexpr uint16_t AVGAIN_32 = + 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) +static constexpr uint16_t AWGAIN_32 = 0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit) -static const uint32_t AVARGAIN_32 = +static constexpr uint16_t AVARGAIN_32 = 0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit) -static const uint32_t AVAGAIN_32 = +static constexpr uint16_t AVAGAIN_32 = 0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit) -static const uint32_t BIGAIN_32 = +static constexpr uint16_t BIGAIN_32 = 0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit) -static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) -static const uint32_t BWGAIN_32 = +static constexpr uint16_t BVGAIN_32 = + 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) +static constexpr uint16_t BWGAIN_32 = 0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit) -static const uint32_t BVARGAIN_32 = +static constexpr uint16_t BVARGAIN_32 = 0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit) -static const uint32_t BVAGAIN_32 = +static constexpr uint16_t BVAGAIN_32 = 0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit) class ADE7953 : public PollingComponent, public sensor::Sensor { diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp index 6b16d933a2..a69b7d19fb 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.cpp +++ b/esphome/components/ade7953_spi/ade7953_spi.cpp @@ -7,6 +7,9 @@ namespace ade7953_spi { static const char *const TAG = "ade7953"; +// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS +constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2; + void AdE7953Spi::setup() { this->spi_setup(); ade7953_base::ADE7953::setup(); @@ -32,6 +35,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) { this->write_byte16(reg); this->transfer_byte(0); this->write_byte16(value); + if (reg == ade7953_base::CONFIG_16) { + delayMicroseconds(CONFIG_LOCK_SETTLE_US); + } this->disable(); return false; } diff --git a/esphome/components/ade7953_spi/ade7953_spi.h b/esphome/components/ade7953_spi/ade7953_spi.h index d96852b9bb..27f6025d98 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.h +++ b/esphome/components/ade7953_spi/ade7953_spi.h @@ -12,7 +12,7 @@ namespace esphome { namespace ade7953_spi { class AdE7953Spi : public ade7953_base::ADE7953, - public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 128e0d0701..45d47a329e 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -12,11 +12,15 @@ CONF_ADS1118_ID = "ads1118_id" ads1118_ns = cg.esphome_ns.namespace("ads1118") ADS1118 = ads1118_ns.class_("ADS1118", cg.Component, spi.SPIDevice) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(ADS1118), - } -).extend(spi.spi_device_schema(cs_pin_required=True)) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ADS1118), + } + ) + .extend(spi.spi_device_schema(cs_pin_required=True)) + .extend(cv.COMPONENT_SCHEMA) +) async def to_code(config): diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 4cfa9e67ec..6491d7d810 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(AGS10Component), - cv.Optional(CONF_TVOC): sensor.sensor_schema( + cv.Required(CONF_TVOC): sensor.sensor_schema( unit_of_measurement=UNIT_PARTS_PER_BILLION, icon=ICON_RADIATOR, accuracy_decimals=0, @@ -97,7 +97,7 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( async def ags10newi2caddress_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - address = await cg.templatable(config[CONF_ADDRESS], args, int) + address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) cg.add(var.set_new_address(address)) return var @@ -112,7 +112,9 @@ AGS10_SET_ZERO_POINT_ACTION_MODE = { AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(AGS10Component), - cv.Required(CONF_MODE): cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True), + cv.Required(CONF_MODE): cv.templatable( + cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True) + ), cv.Optional(CONF_VALUE, default=0xFFFF): cv.templatable(cv.uint16_t), }, ) @@ -127,8 +129,10 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( async def ags10setzeropoint_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - mode = await cg.templatable(config.get(CONF_MODE), args, enumerate) + mode = await cg.templatable( + config.get(CONF_MODE), args, AGS10SetZeroPointActionMode + ) cg.add(var.set_mode(mode)) - value = await cg.templatable(config[CONF_VALUE], args, int) + value = await cg.templatable(config[CONF_VALUE], args, cg.uint16) cg.add(var.set_value(value)) return var diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index a644638f69..b478b573a3 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -43,7 +43,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config.get(CONF_MODE), args, int) + template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8) cg.add(var.set_auto_mute_mode(template_)) return var 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/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index 361e1d101f..279ab214cf 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -9,6 +9,10 @@ from esphome.const import ( CONF_POWER, CONF_SPEED, CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, UNIT_AMPERE, UNIT_CUBIC_METER_PER_HOUR, UNIT_METER, @@ -27,26 +31,35 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_FLOW): sensor.sensor_schema( unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR, accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_HEAD): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CURRENT): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_SPEED): sensor.sensor_schema( unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE, accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), } ) diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index 4b3e1716a4..2697d364ad 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_BATTERY, ENTITY_CATEGORY_DIAGNOSTIC, ICON_BRIGHTNESS_5, + STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) @@ -26,11 +27,13 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_BATTERY, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, ), } ) diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index dd70768105..55a822b9b0 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -19,8 +19,8 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina protected: sensor::Sensor *sensor_{nullptr}; - TemplatableValue upper_threshold_{}; - TemplatableValue lower_threshold_{}; + TemplatableFn upper_threshold_{}; + TemplatableFn lower_threshold_{}; bool raw_state_{false}; // Pre-filter state for hysteresis logic }; diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index b5f87b9b5c..8c13727755 100644 --- a/esphome/components/analog_threshold/binary_sensor.py +++ b/esphome/components/analog_threshold/binary_sensor.py @@ -40,10 +40,10 @@ async def to_code(config): cg.add(var.set_sensor(sens)) if isinstance(config[CONF_THRESHOLD], dict): - lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], float) - upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], float) + lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_) + upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], cg.float_) else: - lower = await cg.templatable(config[CONF_THRESHOLD], [], float) + lower = await cg.templatable(config[CONF_THRESHOLD], [], cg.float_) upper = lower cg.add(var.set_upper_threshold(upper)) cg.add(var.set_lower_threshold(lower)) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 96ee2fb920..33d16f0339 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -129,11 +129,12 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. - // For example "ESPHome v1.10.0 on ESP8266" - string server_info = 3; + // Currently set to ESPHOME_VERSION string literal. + string server_info = 3 [(max_data_length) = 32, (force) = true]; - // The name of the server (App.get_name()) - string name = 4; + // The name of the server (App.get_name() - device hostname) + // max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname) + string name = 4 [(max_data_length) = 31, (force) = true]; } // DEPRECATED in ESPHome 2026.1.0 - Password authentication is no longer supported. @@ -196,12 +197,14 @@ message DeviceInfoRequest { message AreaInfo { uint32 area_id = 1; - string name = 2; + // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA + string name = 2 [(max_data_length) = 120, (force) = true]; } message DeviceInfo { uint32 device_id = 1; - string name = 2; + // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via DEVICE_SCHEMA + string name = 2 [(max_data_length) = 120, (force) = true]; uint32 area_id = 3; } @@ -216,6 +219,16 @@ message SerialProxyInfo { SerialProxyPortType port_type = 2; // Port type (RS232, RS485) } +// DeviceInfoResponse max_data_length values: +// name = 31 (ESPHOME_DEVICE_NAME_MAX_LEN, validated by validate_hostname) +// friendly_name = 120 (core/config.FRIENDLY_NAME_MAX_LEN) +// mac_address/bluetooth_mac_address = 17 (MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1, constexpr) +// esphome_version = 32 (ESPHOME_VERSION string literal) +// compilation_time = 25 (Application::BUILD_TIME_STR_SIZE - 1, constexpr) +// manufacturer = 20 (longest hardcoded literal: "Nordic Semiconductor") +// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) +// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) +// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -224,28 +237,30 @@ message DeviceInfoResponse { // with older ESPHome versions that still send this field. bool uses_password = 1 [deprecated = true]; - // The name of the node, given by "App.set_name()" - string name = 2; + // The name of the node, given by "App.set_name()" - device hostname + string name = 2 [(max_data_length) = 31, (force) = true]; // The mac address of the device. For example "AC:BC:32:89:0E:A9" - string mac_address = 3; + string mac_address = 3 [(max_data_length) = 17, (force) = true]; // A string describing the ESPHome version. For example "1.10.0" - string esphome_version = 4; + string esphome_version = 4 [(max_data_length) = 32, (force) = true]; // A string describing the date of compilation, this is generated by the compiler // and therefore may not be in the same format all the time. // If the user isn't using ESPHome, this will also not be set. - string compilation_time = 5; + string compilation_time = 5 [(max_data_length) = 25, (force) = true]; // The model of the board. For example NodeMCU - string model = 6; + // max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas) + string model = 6 [(max_data_length) = 127, (force) = true]; bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"]; // The esphome project details if set - string project_name = 8 [(field_ifdef) = "ESPHOME_PROJECT_NAME"]; - string project_version = 9 [(field_ifdef) = "ESPHOME_PROJECT_NAME"]; + // max_data_length matches core/config.PROJECT_MAX_LENGTH + string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; + string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"]; @@ -253,18 +268,18 @@ message DeviceInfoResponse { uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; - string manufacturer = 12; + string manufacturer = 12 [(max_data_length) = 20, (force) = true]; - string friendly_name = 13; + string friendly_name = 13 [(max_data_length) = 120, (force) = true]; // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; - string suggested_area = 16 [(field_ifdef) = "USE_AREAS"]; + string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" - string bluetooth_mac_address = 18 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"]; @@ -308,6 +323,12 @@ enum EntityCategory { ENTITY_CATEGORY_DIAGNOSTIC = 2; } +// Entity field max_data_length values match Python validation constants: +// name/object_id = 120 (config_validation.NAME_MAX_LENGTH) +// icon = 63 (core/config.ICON_MAX_LENGTH) +// device_class = 47 (core/config.DEVICE_CLASS_MAX_LENGTH) +// unit_of_measurement = 63 (core/config.UNIT_OF_MEASUREMENT_MAX_LENGTH) + // ==================== BINARY SENSOR ==================== message ListEntitiesBinarySensorResponse { option (id) = 12; @@ -315,15 +336,15 @@ message ListEntitiesBinarySensorResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BINARY_SENSOR"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string device_class = 5; + string device_class = 5 [(max_data_length) = 47]; bool is_status_binary_sensor = 6; bool disabled_by_default = 7; - string icon = 8 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 8 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 9; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } @@ -349,17 +370,17 @@ message ListEntitiesCoverResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_COVER"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id bool assumed_state = 5; bool supports_position = 6; bool supports_tilt = 7; - string device_class = 8; + string device_class = 8 [(max_data_length) = 47]; bool disabled_by_default = 9; - string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 11; bool supports_stop = 12; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; @@ -433,9 +454,9 @@ message ListEntitiesFanResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_FAN"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id bool supports_oscillation = 5; @@ -443,7 +464,7 @@ message ListEntitiesFanResponse { bool supports_direction = 7; int32 supported_speed_count = 8; bool disabled_by_default = 9; - string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 11; repeated string supported_preset_modes = 12 [(container_pointer_no_template) = "std::vector"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; @@ -521,9 +542,9 @@ message ListEntitiesLightResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_LIGHT"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id repeated ColorMode supported_color_modes = 12 [(container_pointer_no_template) = "light::ColorModeMask"]; @@ -540,7 +561,7 @@ message ListEntitiesLightResponse { float max_mireds = 10; repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"]; bool disabled_by_default = 13; - string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 15; uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"]; } @@ -626,16 +647,16 @@ message ListEntitiesSensorResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; - string unit_of_measurement = 6; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string unit_of_measurement = 6 [(max_data_length) = 63]; int32 accuracy_decimals = 7; bool force_update = 8; - string device_class = 9; + string device_class = 9 [(max_data_length) = 47]; SensorStateClass state_class = 10; // Last reset type removed in 2021.9.0 // Deprecated in API version 1.5 @@ -666,16 +687,16 @@ message ListEntitiesSwitchResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SWITCH"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool assumed_state = 6; bool disabled_by_default = 7; EntityCategory entity_category = 8; - string device_class = 9; + string device_class = 9 [(max_data_length) = 47]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message SwitchStateResponse { @@ -708,15 +729,15 @@ message ListEntitiesTextSensorResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_TEXT_SENSOR"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8; + string device_class = 8 [(max_data_length) = 47]; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; } message TextSensorStateResponse { @@ -971,12 +992,12 @@ message ListEntitiesCameraResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_CAMERA"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id bool disabled_by_default = 5; - string icon = 6 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 6 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; } @@ -1056,9 +1077,9 @@ message ListEntitiesClimateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_CLIMATE"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id bool supports_current_temperature = 5; // Deprecated: use feature_flags @@ -1078,7 +1099,7 @@ message ListEntitiesClimateResponse { repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"]; repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::vector"]; bool disabled_by_default = 18; - string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 20; float visual_current_temperature_step = 21; bool supports_current_humidity = 22; // Deprecated: use feature_flags @@ -1167,10 +1188,10 @@ message ListEntitiesWaterHeaterResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_WATER_HEATER"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; - string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"]; + string name = 3 [(max_data_length) = 120, (force) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 5; EntityCategory entity_category = 6; uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; @@ -1243,20 +1264,20 @@ message ListEntitiesNumberResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_NUMBER"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; float min_value = 6; float max_value = 7; float step = 8; bool disabled_by_default = 9; EntityCategory entity_category = 10; - string unit_of_measurement = 11; + string unit_of_measurement = 11 [(max_data_length) = 63]; NumberMode mode = 12; - string device_class = 13; + string device_class = 13 [(max_data_length) = 47]; uint32 device_id = 14 [(field_ifdef) = "USE_DEVICES"]; } message NumberStateResponse { @@ -1292,12 +1313,12 @@ message ListEntitiesSelectResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SELECT"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; repeated string options = 6 [(container_pointer_no_template) = "FixedVector"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; @@ -1336,12 +1357,12 @@ message ListEntitiesSirenResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SIREN"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; repeated string tones = 7 [(container_pointer_no_template) = "FixedVector"]; bool supports_duration = 8; @@ -1399,12 +1420,12 @@ message ListEntitiesLockResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_LOCK"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; bool assumed_state = 8; @@ -1448,15 +1469,15 @@ message ListEntitiesButtonResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BUTTON"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8; + string device_class = 8 [(max_data_length) = 47]; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; } message ButtonCommandRequest { @@ -1515,12 +1536,12 @@ message ListEntitiesMediaPlayerResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_MEDIA_PLAYER"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; @@ -1606,7 +1627,7 @@ message BluetoothLEAdvertisementResponse { message BluetoothLERawAdvertisement { uint64 address = 1 [(force) = true]; sint32 rssi = 2 [(force) = true]; - uint32 address_type = 3; + uint32 address_type = 3 [(max_value) = 4]; bytes data = 4 [(fixed_array_size) = 62, (force) = true]; } @@ -2103,11 +2124,11 @@ message ListEntitiesAlarmControlPanelResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_ALARM_CONTROL_PANEL"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 supported_features = 8; @@ -2150,11 +2171,11 @@ message ListEntitiesTextResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_TEXT"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; @@ -2198,12 +2219,12 @@ message ListEntitiesDateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_DATE"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; @@ -2245,12 +2266,12 @@ message ListEntitiesTimeResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_TIME"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; @@ -2292,15 +2313,15 @@ message ListEntitiesEventResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_EVENT"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8; + string device_class = 8 [(max_data_length) = 47]; repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; @@ -2323,15 +2344,15 @@ message ListEntitiesValveResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_VALVE"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8; + string device_class = 8 [(max_data_length) = 47]; bool assumed_state = 9; bool supports_position = 10; @@ -2378,12 +2399,12 @@ message ListEntitiesDateTimeResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_DATETIME"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; @@ -2421,15 +2442,15 @@ message ListEntitiesUpdateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_UPDATE"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; + string name = 3 [(max_data_length) = 120, (force) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8; + string device_class = 8 [(max_data_length) = 47]; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; } message UpdateStateResponse { @@ -2504,10 +2525,10 @@ message ListEntitiesInfraredResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_INFRARED"; - string object_id = 1; + string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3; - string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"]; + string name = 3 [(max_data_length) = 120, (force) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; bool disabled_by_default = 5; EntityCategory entity_category = 6; uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0f456ecd0c..4663456da6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -52,11 +52,11 @@ namespace esphome::api { -// Read a maximum of 5 messages per loop iteration to prevent starving other components. +// Maximum messages to read per loop iteration to prevent starving other components. // This is a balance between API responsiveness and allowing other components to run. // Since each message could contain multiple protobuf messages when using packet batching, // this limits the number of messages processed, not the number of TCP packets. -static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5; +static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 10; static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; @@ -72,6 +72,14 @@ static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000; static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); +// Cross-validate C++ constants against proto max_data_length annotations in api.proto +static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17, + "Update max_data_length for mac_address/bluetooth_mac_address in api.proto"); +static_assert(Application::BUILD_TIME_STR_SIZE - 1 == 25, "Update max_data_length for compilation_time in api.proto"); +static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esphome_version in api.proto"); +static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for name in api.proto"); +static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -212,10 +220,17 @@ void APIConnection::loop() { } const uint32_t now = App.get_loop_component_start_time(); - // Check if socket has data ready before attempting to read - if (this->helper_->is_socket_ready()) { + // Check if socket has data ready before attempting to read. + // Also try reading if we hit the message limit last time — LWIP's rcvevent + // (used by is_socket_ready) tracks pbuf dequeues, not bytes. When multiple + // messages share a TCP segment, the last message's data stays in LWIP's + // lastdata cache after rcvevent hits 0, making is_socket_ready() return false + // even though data remains. + if (this->helper_->is_socket_ready() || this->flags_.may_have_remaining_data) { + this->flags_.may_have_remaining_data = false; // Read up to MAX_MESSAGES_PER_LOOP messages per loop to improve throughput - for (uint8_t message_count = 0; message_count < MAX_MESSAGES_PER_LOOP; message_count++) { + uint8_t message_count = 0; + for (; message_count < MAX_MESSAGES_PER_LOOP; message_count++) { ReadPacketBuffer buffer; err = this->helper_->read_packet(&buffer); if (err == APIError::WOULD_BLOCK) { @@ -237,6 +252,11 @@ void APIConnection::loop() { return; } } + // If we hit the limit, there may be more data remaining in LWIP's + // lastdata cache that rcvevent doesn't account for. + if (message_count == MAX_MESSAGES_PER_LOOP) { + this->flags_.may_have_remaining_data = true; + } } // Process deferred batch if scheduled and timer has expired @@ -307,6 +327,8 @@ void APIConnection::process_active_iterator_() { this->destroy_active_iterator_(); if (this->flags_.state_subscription) { this->begin_iterator_(ActiveIterator::INITIAL_STATE); + } else { + this->finalize_iterator_sync_(); } } else { this->process_iterator_batch_(this->iterator_storage_.list_entities); @@ -314,21 +336,27 @@ void APIConnection::process_active_iterator_() { } else { // INITIAL_STATE if (this->iterator_storage_.initial_state.completed()) { this->destroy_active_iterator_(); - // Process any remaining batched messages immediately - if (!this->deferred_batch_.empty()) { - this->process_batch_(); - } - // Now that everything is sent, enable immediate sending for future state changes - this->flags_.should_try_send_immediately = true; - // Release excess memory from buffers that grew during initial sync - this->deferred_batch_.release_buffer(); - this->helper_->release_buffers(); + this->finalize_iterator_sync_(); } else { this->process_iterator_batch_(this->iterator_storage_.initial_state); } } } +void APIConnection::finalize_iterator_sync_() { + // Flush any remaining batched messages immediately so clients + // receive completion responses (e.g. ListEntitiesDoneResponse) + // without waiting for the batch timer. + if (!this->deferred_batch_.empty()) { + this->process_batch_(); + } + // Enable immediate sending for future state changes + this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); +} + void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); size_t max_batch = this->get_max_batch_size_(); @@ -398,7 +426,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif - return encode_to_buffer(size_fn(&msg), encode_fn, &msg, conn, remaining_size); + return encode_to_buffer_slow(size_fn(&msg), encode_fn, &msg, conn, remaining_size); } uint16_t APIConnection::fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg, @@ -1716,6 +1744,7 @@ bool APIConnection::send_device_info_response_() { static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); resp.manufacturer = MANUFACTURER; #endif + static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto"); #undef ESPHOME_MANUFACTURER #ifdef USE_ESP8266 @@ -1993,51 +2022,15 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M size_t write_start = shared_buf.size(); shared_buf.resize(write_start + payload_size); ProtoWriteBuffer buffer{&shared_buf, write_start}; - encode_fn(msg, buffer); + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type); } -// Encodes a message to the buffer and returns the total number of bytes used, -// including header and footer overhead. Returns 0 if the message doesn't fit. -uint16_t APIConnection::encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, - APIConnection *conn, uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - // Cache frame sizes to avoid repeated virtual calls - const uint8_t header_padding = conn->helper_->frame_header_padding(); - const uint8_t footer_size = conn->helper_->frame_footer_size(); +// encode_to_buffer is defined inline in api_connection.h (ESPHOME_ALWAYS_INLINE) - // Calculate total size with padding for buffer allocation - size_t total_calculated_size = calculated_size + header_padding + footer_size; - - // Check if it fits - if (total_calculated_size > remaining_size) - return 0; // Doesn't fit - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - - size_t to_add; - if (conn->flags_.batch_first_message) { - // First message - buffer already prepared by caller, just clear flag - conn->flags_.batch_first_message = false; - to_add = calculated_size; - } else { - // Batch message second or later - // Reserve for full message, resize to include footer gap + header padding + payload - to_add = total_calculated_size; - } - - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer); - - // Return total size (header + payload + footer) - return static_cast(total_calculated_size); +// Noinline version for cold paths — single shared copy +uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { + return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size); } bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE); @@ -2105,6 +2098,13 @@ void APIConnection::process_batch_() { return; } + // Ensure TCP_NODELAY is on before draining overflow and writing batch data. + // Log messages enable Nagle (NODELAY off) to coalesce small packets. + // If Nagle is still on when we try to drain, LWIP holds data in the + // Nagle buffer, the TCP send buffer stays full, and the overflow + // buffer can never drain — blocking the batch write indefinitely. + this->helper_->set_nodelay_for_message(false); + // Try to clear buffer first if (!this->try_to_clear_buffer(true)) { // Can't write now, we'll try again later @@ -2164,17 +2164,15 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items "MessageInfo must remain trivially destructible with this placement-new approach"); const size_t messages_to_process = std::min(num_items, MAX_MESSAGES_PER_BATCH); - const uint8_t frame_overhead = header_padding + footer_size; // Stack-allocated array for message info alignas(MessageInfo) char message_info_storage[MAX_MESSAGES_PER_BATCH * sizeof(MessageInfo)]; MessageInfo *message_info = reinterpret_cast(message_info_storage); size_t items_processed = 0; uint16_t remaining_size = std::numeric_limits::max(); - // Track where each message's header padding begins in the buffer - // For plaintext: this is where the 6-byte header padding starts - // For noise: this is where the 7-byte header padding starts - // The actual message data follows after the header padding + // Track where each message's header begins in the buffer + // First message: offset 0 (max padding, may have unused leading bytes) + // Subsequent messages: offset points to exact header start (no gaps) uint32_t current_offset = 0; // Process items and encode directly to buffer (up to our limit) @@ -2190,13 +2188,14 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items } // Message was encoded successfully - // payload_size is header_padding + actual payload size + footer_size - uint16_t proto_payload_size = payload_size - frame_overhead; + // payload_size = header_size + proto_payload_size + footer_size + uint16_t proto_payload_size = payload_size - this->batch_header_size_ - footer_size; // Use placement new to construct MessageInfo in pre-allocated stack array // This avoids default-constructing all MAX_MESSAGES_PER_BATCH elements // Explicit destruction is not needed because MessageInfo is trivially destructible, // as ensured by the static_assert in its definition. - new (&message_info[items_processed++]) MessageInfo(item.message_type, current_offset, proto_payload_size); + new (&message_info[items_processed++]) + MessageInfo(item.message_type, current_offset, proto_payload_size, this->batch_header_size_); // After first message, set remaining size to MAX_BATCH_PACKET_SIZE to avoid fragmentation if (items_processed == 1) { remaining_size = MAX_BATCH_PACKET_SIZE; @@ -2246,6 +2245,7 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item, uint32_t remaining_size, bool batch_first) { this->flags_.batch_first_message = batch_first; + this->batch_message_type_ = item.message_type; #ifdef USE_EVENT // Events need aux_data_index to look up event type from entity if (item.message_type == EventResponse::MESSAGE_TYPE) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 13d5273ecb..7d08797090 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -20,6 +20,9 @@ #ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" #endif +#ifdef USE_ESP8266_CRASH_HANDLER +#include "esphome/components/esp8266/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -273,9 +276,13 @@ class APIConnection final : public APIServerConnectionBase { App.schedule_dump_config(); #ifdef USE_ESP32_CRASH_HANDLER esp32::crash_handler_log(); + esp32::crash_handler_clear(); #endif #ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif +#ifdef USE_ESP8266_CRASH_HANDLER + esp8266::crash_handler_log(); #endif } #ifdef USE_API_HOMEASSISTANT_SERVICES @@ -318,7 +325,7 @@ class APIConnection final : public APIServerConnectionBase { void on_no_setup_connection(); // Function pointer type for type-erased message encoding - using MessageEncodeFn = void (*)(const void *, ProtoWriteBuffer &); + using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM); // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); @@ -397,21 +404,66 @@ class APIConnection final : public APIServerConnectionBase { } // Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0) - static void encode_msg_noop(const void *, ProtoWriteBuffer &) {} + static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { + return buf.get_pos(); + } // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Non-template buffer management for batch encoding - static uint16_t encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, - APIConnection *conn, uint32_t remaining_size); + // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. + // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. + static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, + uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); - // Thin template wrapper — computes size, delegates buffer work to non-template helper + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; + } + + // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). + // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. + static uint16_t encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size); + + // Thin template wrapper — uses noinline encode_to_buffer_slow since + // encode_message_to_buffer callers are cold paths (zero-payload control messages). + // Hot paths (state/info) go through fill_and_encode_entity_state/info instead. + // batch_message_type_ is already set by dispatch_message_ before reaching here. template static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) { if constexpr (T::ESTIMATED_SIZE == 0) { - return encode_to_buffer(0, &encode_msg_noop, &msg, conn, remaining_size); + return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size); } else { - return encode_to_buffer(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); + return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); } } @@ -610,6 +662,7 @@ class APIConnection final : public APIServerConnectionBase { // Helper methods for iterator lifecycle management void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); + void finalize_iterator_sync_(); #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif @@ -718,6 +771,7 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_scheduled : 1; uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent + uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check #ifdef HAS_PROTO_MESSAGE_DUMP uint8_t log_only_mode : 1; #endif @@ -726,9 +780,14 @@ class APIConnection final : public APIServerConnectionBase { // 2-byte types immediately after flags_ (no padding between them) uint16_t client_api_version_major_{0}; uint16_t client_api_version_minor_{0}; - // 1-byte type to fill padding + // 1-byte types to fill remaining space before next 4-byte boundary ActiveIterator active_iterator_{ActiveIterator::NONE}; - // Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary + uint8_t batch_message_type_{0}; // Current message type during batch encoding + // Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary + + // Actual header size used by encode_to_buffer for the current message. + // Read by process_batch_multi_ to pass into MessageInfo. + uint8_t batch_header_size_{0}; uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } // Message will use 8 more bytes than the minimum size, and typical diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 6d3bd51b58..90353b6402 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -100,10 +100,17 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("UNKNOWN"); } +#ifdef HELPER_LOG_PACKETS +void APIFrameHelper::log_packet_sending_(const void *data, uint16_t len) { + LOG_PACKET_SENDING(reinterpret_cast(data), len); +} +#endif + APIError APIFrameHelper::drain_overflow_and_handle_errors_() { if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) { int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + if (err != EWOULDBLOCK && err != EAGAIN) { + this->state_ = State::FAILED; HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } @@ -111,45 +118,58 @@ APIError APIFrameHelper::drain_overflow_and_handle_errors_() { return APIError::OK; } -// Write data to socket, overflow to backlog buffer if LWIP TCP send buffer is full. -// Returns OK if all data was sent or successfully queued. -// Returns SOCKET_WRITE_FAILED on hard error (sets state to FAILED). -APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { +// Single-buffer write path: wraps in iovec and delegates. +APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t sent) { + struct iovec iov = {const_cast(data), len}; + APIError err = this->write_raw_iov_(&iov, 1, len, sent); #ifdef HELPER_LOG_PACKETS - for (int i = 0; i < iovcnt; i++) { - LOG_PACKET_SENDING(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); - } + // Log after write/enqueue so re-entrant log sends can't corrupt data before it's sent + if (err == APIError::OK) + LOG_PACKET_SENDING(reinterpret_cast(data), len); #endif + return err; +} - uint16_t skip = 0; - - // Drain any existing backlog first - if (!this->overflow_buf_.empty()) [[unlikely]] { - APIError err = this->drain_overflow_and_handle_errors_(); - if (err != APIError::OK) - return err; - } - - // If backlog is clear, try direct send - if (this->overflow_buf_.empty()) [[likely]] { - ssize_t sent = - (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); - - if (sent == -1) [[unlikely]] { +// Handles partial writes, errors, and overflow buffering. +// Called when the inline fast path couldn't complete the write, +// or directly from cold paths (handshake, error handling). +APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) { + if (sent <= 0) { + if (sent == WRITE_NOT_ATTEMPTED) { + // Cold path: no write attempted yet, drain overflow and try + if (!this->overflow_buf_.empty()) { + APIError err = this->drain_overflow_and_handle_errors_(); + if (err != APIError::OK) + return err; + } + if (this->overflow_buf_.empty()) { + sent = this->write_iov_to_socket_(iov, iovcnt); + if (sent == static_cast(total_write_len)) + return APIError::OK; + // Partial write or -1: fall through to error check / enqueue below + } else { + // Overflow backlog remains after drain; skip socket write, enqueue everything + sent = 0; + } + } + // WRITE_FAILED (-1): fast path or retry write returned -1, check errno + if (sent == WRITE_FAILED) { int err = errno; - if (this->check_socket_write_err_(err) != APIError::WOULD_BLOCK) { + if (err != EWOULDBLOCK && err != EAGAIN) { + this->state_ = State::FAILED; HELPER_LOG("Socket write failed with errno %d", err); return APIError::SOCKET_WRITE_FAILED; } - } else if (static_cast(sent) >= total_write_len) [[likely]] { - return APIError::OK; - } else { - skip = static_cast(sent); + sent = 0; // Treat WOULD_BLOCK as zero bytes sent } } + // Full write completed (possible when called directly, not via write_raw_fast_buf_) + if (sent == static_cast(total_write_len)) + return APIError::OK; + // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, skip)) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { HELPER_LOG("Overflow buffer full, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 72ccf8aa56..f98eca8076 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -49,12 +49,17 @@ struct ReadPacketBuffer { }; // Packed message info structure to minimize memory usage +// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits. +// The noise wire format encodes types as 16-bit, but the high byte is always 0. +// If message types ever exceed 255, this and encrypt_noise_message_ must be updated. struct MessageInfo { uint16_t offset; // Offset in buffer where message starts uint16_t payload_size; // Size of the message payload uint8_t message_type; // Message type (0-255) + uint8_t header_size; // Actual header size used (avoids recomputation in write path) - MessageInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} + MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr) + : offset(off), payload_size(size), message_type(type), header_size(hdr) {} }; enum class APIError : uint16_t { @@ -161,23 +166,39 @@ class APIFrameHelper { this->nodelay_counter_ = 0; } } - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - // Resize buffer to include footer space if needed (e.g. Noise MAC) - if (frame_footer_size_) - buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - MessageInfo msg{type, 0, - static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); - } - // Write multiple protobuf messages in a single operation - // messages contains (message_type, offset, length) for each message in the buffer - // The buffer contains all messages with appropriate padding before each + // Write a single protobuf message - the hot path (87-100% of all writes). + // Caller must ensure state is DATA before calling. + virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + // Write multiple protobuf messages in a single batched operation. + // Caller must ensure state is DATA and messages is not empty. + // messages contains (message_type, offset, length) for each message in the buffer. + // The buffer contains all messages with appropriate padding before each. virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) = 0; - // Get the frame header padding required by this protocol + // Get the maximum frame header padding required by this protocol (worst case) uint8_t frame_header_padding() const { return frame_header_padding_; } + // Get the actual frame header size for a specific message. + // For noise: always returns frame_header_padding_ (fixed 7-byte header). + // For plaintext: computes actual size from varint lengths (3-6 bytes). + // Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC + // footer, plaintext has footer=0). If a protocol with a plaintext footer is ever + // added, this should become a virtual method. + uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + return this->frame_footer_size_ + ? this->frame_header_padding_ + : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); +#elif defined(USE_API_NOISE) + return this->frame_header_padding_; +#else // USE_API_PLAINTEXT only + return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); +#endif + } // Get the frame footer size required by this protocol uint8_t frame_footer_size() const { return frame_footer_size_; } - // Check if socket has data ready to read + // Check if socket has buffered data ready to read. + // Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK) + // or track that they stopped early and retry without this check. + // See Socket::ready() for details. bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } // Release excess memory from internal buffers after initial sync void release_buffers() { @@ -196,18 +217,41 @@ class APIFrameHelper { // Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors. APIError drain_overflow_and_handle_errors_(); - // Common implementation for writing raw data to socket - APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); + // Sentinel values for the sent parameter in write_raw_ methods + static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1 + static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet - // Check if a socket write errno is a hard error (not WOULD_BLOCK/EAGAIN). - // Returns WOULD_BLOCK for transient errors, SOCKET_WRITE_FAILED for hard errors. - APIError check_socket_write_err_(int err) { - if (err == EWOULDBLOCK || err == EAGAIN) - return APIError::WOULD_BLOCK; - this->state_ = State::FAILED; - return APIError::SOCKET_WRITE_FAILED; + // Dispatch to write() or writev() based on iovec count + inline ssize_t ESPHOME_ALWAYS_INLINE write_iov_to_socket_(const struct iovec *iov, int iovcnt) { + return (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); } + // Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages) + // These inline the fast path (overflow empty + full write) and tail-call the out-of-line + // slow path only on failure/partial write. + inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_buf_(const void *data, uint16_t len) { + if (this->overflow_buf_.empty()) [[likely]] { + ssize_t sent = this->socket_->write(data, len); + if (sent == static_cast(len)) [[likely]] { +#ifdef HELPER_LOG_PACKETS + this->log_packet_sending_(data, len); +#endif + return APIError::OK; + } + // sent is -1 (WRITE_FAILED) or partial write count + return this->write_raw_buf_(data, len, sent); + } + return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED); + } + // Out-of-line write paths: handle partial writes, errors, overflow buffering + // sent: WRITE_NOT_ATTEMPTED (cold path), WRITE_FAILED (fast path write returned -1), or bytes sent (partial write) + APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = WRITE_NOT_ATTEMPTED); + APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, + ssize_t sent = WRITE_NOT_ATTEMPTED); +#ifdef HELPER_LOG_PACKETS + void log_packet_sending_(const void *data, uint16_t len); +#endif + // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 78e87793fc..6dba64a7f8 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -47,15 +47,8 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; format_hex_pretty_to(hex_buf_, (buffer).data(), \ (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ } while (0) -#define LOG_PACKET_SENDING(data, len) \ - do { \ - char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ - ESP_LOGVV(TAG, "Sending raw: %s", \ - format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ - } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) -#define LOG_PACKET_SENDING(data, len) ((void) 0) #endif /// Convert a noise error code to a readable error @@ -244,132 +237,144 @@ APIError APINoiseFrameHelper::try_read_frame_() { * If an error occurred, returns that error. Only returns OK if the transport is ready for data * traffic. */ +// Split into per-state methods so the compiler doesn't allocate stack space +// for all branches simultaneously. On RP2040 the core0 stack lives in a 4KB +// scratch RAM bank; the Noise crypto path (curve25519) needs ~2KB+ of stack, +// so every byte saved in the caller matters. APIError APINoiseFrameHelper::state_action_() { - int err; - APIError aerr; - if (state_ == State::INITIALIZE) { - HELPER_LOG("Bad state for method: %d", (int) state_); - return APIError::BAD_STATE; + switch (this->state_) { + case State::INITIALIZE: + HELPER_LOG("Bad state for method: %d", (int) this->state_); + return APIError::BAD_STATE; + case State::CLIENT_HELLO: + return this->state_action_client_hello_(); + case State::SERVER_HELLO: + return this->state_action_server_hello_(); + case State::HANDSHAKE: + return this->state_action_handshake_(); + case State::CLOSED: + case State::FAILED: + return APIError::BAD_STATE; + default: + return APIError::OK; } - if (state_ == State::CLIENT_HELLO) { - // waiting for client hello - aerr = this->try_read_frame_(); - if (aerr != APIError::OK) { - return handle_handshake_frame_error_(aerr); - } - // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); - size_t rx_size = this->rx_buf_.size(); - this->prologue_.resize(old_size + 2 + rx_size); - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; - if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); - } - - state_ = State::SERVER_HELLO; +} +APIError APINoiseFrameHelper::state_action_client_hello_() { + // waiting for client hello + APIError aerr = this->try_read_frame_(); + if (aerr != APIError::OK) { + return handle_handshake_frame_error_(aerr); } - if (state_ == State::SERVER_HELLO) { - // send server hello - const auto &name = App.get_name(); - char mac[MAC_ADDRESS_BUFFER_SIZE]; - get_mac_address_into_buffer(mac); - - // Calculate positions and sizes - size_t name_len = name.size() + 1; // including null terminator - size_t name_offset = 1; - size_t mac_offset = name_offset + name_len; - size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE; - - // 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null) - // + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null) - constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE; - uint8_t msg[max_msg_size]; - - // chosen proto - msg[0] = 0x01; - - // node name, terminated by null byte - std::memcpy(msg + name_offset, name.c_str(), name_len); - // node mac, terminated by null byte - std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); - - aerr = write_frame_(msg, total_size); - if (aerr != APIError::OK) - return aerr; - - // start handshake - aerr = init_handshake_(); - if (aerr != APIError::OK) - return aerr; - - state_ = State::HANDSHAKE; + // ignore contents, may be used in future for flags + // Resize for: existing prologue + 2 size bytes + frame data + size_t old_size = this->prologue_.size(); + size_t rx_size = this->rx_buf_.size(); + this->prologue_.resize(old_size + 2 + rx_size); + this->prologue_[old_size] = (uint8_t) (rx_size >> 8); + this->prologue_[old_size + 1] = (uint8_t) rx_size; + if (rx_size > 0) { + std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); } - if (state_ == State::HANDSHAKE) { - int action = noise_handshakestate_get_action(handshake_); - if (action == NOISE_ACTION_READ_MESSAGE) { - // waiting for handshake msg - aerr = this->try_read_frame_(); - if (aerr != APIError::OK) { - return handle_handshake_frame_error_(aerr); - } - if (this->rx_buf_.empty()) { - send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); - return APIError::BAD_HANDSHAKE_ERROR_BYTE; - } else if (this->rx_buf_[0] != 0x00) { - HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]); - send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); - return APIError::BAD_HANDSHAKE_ERROR_BYTE; - } - - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); - err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr); - if (err != 0) { - // Special handling for MAC failure - send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") - : LOG_STR("Handshake error")); - return handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), - APIError::HANDSHAKESTATE_READ_FAILED); - } - - aerr = check_handshake_finished_(); - if (aerr != APIError::OK) - return aerr; - } else if (action == NOISE_ACTION_WRITE_MESSAGE) { - uint8_t buffer[65]; - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); - - err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr); - APIError aerr_write = handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), - APIError::HANDSHAKESTATE_WRITE_FAILED); - if (aerr_write != APIError::OK) - return aerr_write; - buffer[0] = 0x00; // success - - aerr = write_frame_(buffer, mbuf.size + 1); - if (aerr != APIError::OK) - return aerr; - aerr = check_handshake_finished_(); - if (aerr != APIError::OK) - return aerr; - } else { - // bad state for action - state_ = State::FAILED; - HELPER_LOG("Bad action for handshake: %d", action); - return APIError::HANDSHAKESTATE_BAD_STATE; - } - } - if (state_ == State::CLOSED || state_ == State::FAILED) { - return APIError::BAD_STATE; - } + state_ = State::SERVER_HELLO; return APIError::OK; } +APIError APINoiseFrameHelper::state_action_server_hello_() { + // send server hello + const auto &name = App.get_name(); + char mac[MAC_ADDRESS_BUFFER_SIZE]; + get_mac_address_into_buffer(mac); + + // Calculate positions and sizes + size_t name_len = name.size() + 1; // including null terminator + size_t name_offset = 1; + size_t mac_offset = name_offset + name_len; + size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE; + + // 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null) + // + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null) + constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE; + uint8_t msg[max_msg_size]; + + // chosen proto + msg[0] = 0x01; + + // node name, terminated by null byte + std::memcpy(msg + name_offset, name.c_str(), name_len); + // node mac, terminated by null byte + std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); + + APIError aerr = write_frame_(msg, total_size); + if (aerr != APIError::OK) + return aerr; + + // start handshake + aerr = init_handshake_(); + if (aerr != APIError::OK) + return aerr; + + state_ = State::HANDSHAKE; + return APIError::OK; +} +APIError APINoiseFrameHelper::state_action_handshake_() { + int action = noise_handshakestate_get_action(this->handshake_); + if (action == NOISE_ACTION_READ_MESSAGE) { + return this->state_action_handshake_read_(); + } else if (action == NOISE_ACTION_WRITE_MESSAGE) { + return this->state_action_handshake_write_(); + } + // bad state for action + this->state_ = State::FAILED; + HELPER_LOG("Bad action for handshake: %d", action); + return APIError::HANDSHAKESTATE_BAD_STATE; +} +APIError APINoiseFrameHelper::state_action_handshake_read_() { + APIError aerr = this->try_read_frame_(); + if (aerr != APIError::OK) { + return this->handle_handshake_frame_error_(aerr); + } + + if (this->rx_buf_.empty()) { + this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); + return APIError::BAD_HANDSHAKE_ERROR_BYTE; + } else if (this->rx_buf_[0] != 0x00) { + HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]); + this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); + return APIError::BAD_HANDSHAKE_ERROR_BYTE; + } + + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); + int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr); + if (err != 0) { + // Special handling for MAC failure + this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") + : LOG_STR("Handshake error")); + return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), + APIError::HANDSHAKESTATE_READ_FAILED); + } + + return this->check_handshake_finished_(); +} +APIError APINoiseFrameHelper::state_action_handshake_write_() { + uint8_t buffer[65]; + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); + + int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr); + APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), + APIError::HANDSHAKESTATE_WRITE_FAILED); + if (aerr != APIError::OK) + return aerr; + buffer[0] = 0x00; // success + + aerr = this->write_frame_(buffer, mbuf.size + 1); + if (aerr != APIError::OK) + return aerr; + return this->check_handshake_finished_(); +} void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { // Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes uint8_t data[32]; @@ -452,65 +457,83 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = this->check_data_state_(); +// Encrypt a single noise message in place and return the encrypted frame length. +// Returns APIError::OK on success. +APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, + uint16_t &encrypted_len_out) { + // Write noise header + buf_start[0] = 0x01; // indicator + // buf_start[1], buf_start[2] to be set after encryption + + // Write message header (to be encrypted) + constexpr uint8_t msg_offset = 3; + buf_start[msg_offset] = static_cast(message_type >> 8); // type high byte + buf_start[msg_offset + 1] = static_cast(message_type); // type low byte + buf_start[msg_offset + 2] = static_cast(payload_size >> 8); // data_len high byte + buf_start[msg_offset + 3] = static_cast(payload_size); // data_len low byte + // payload data is already in the buffer starting at offset + 7 + + // Encrypt the message in place + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + payload_size, 4 + payload_size + this->frame_footer_size_); + + int err = noise_cipherstate_encrypt(this->send_cipher_, &mbuf); + APIError aerr = + this->handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); if (aerr != APIError::OK) return aerr; - if (messages.empty()) { - return APIError::OK; - } + // Fill in the encrypted size + buf_start[1] = static_cast(mbuf.size >> 8); + buf_start[2] = static_cast(mbuf.size); + encrypted_len_out = static_cast(3 + mbuf.size); // indicator + size + encrypted data + return APIError::OK; +} + +APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); +#endif + + // Resize buffer to include footer space for Noise MAC + if (this->frame_footer_size_) + buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_); + + uint16_t payload_size = + static_cast(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_); + uint8_t *buf_start = buffer.get_buffer()->data(); + uint16_t encrypted_len; + APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len); + if (aerr != APIError::OK) + return aerr; + return this->write_raw_fast_buf_(buf_start, encrypted_len); +} + +APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); + assert(!messages.empty()); +#endif + + // Noise messages are already contiguous in the buffer: + // HEADER_PADDING (7) exactly matches the fixed header size, and + // footer space (16) is consumed by the encryption MAC. uint8_t *buffer_data = buffer.get_buffer()->data(); - - // Stack-allocated iovec array - no heap allocation - StaticVector iovs; + uint8_t *write_start = buffer_data + messages[0].offset; uint16_t total_write_len = 0; - // We need to encrypt each message in place for (const auto &msg : messages) { - // The buffer already has padding at offset uint8_t *buf_start = buffer_data + msg.offset; - - // Write noise header - buf_start[0] = 0x01; // indicator - // buf_start[1], buf_start[2] to be set after encryption - - // Write message header (to be encrypted) - constexpr uint8_t msg_offset = 3; - buf_start[msg_offset] = static_cast(msg.message_type >> 8); // type high byte - buf_start[msg_offset + 1] = static_cast(msg.message_type); // type low byte - buf_start[msg_offset + 2] = static_cast(msg.payload_size >> 8); // data_len high byte - buf_start[msg_offset + 3] = static_cast(msg.payload_size); // data_len low byte - // payload data is already in the buffer starting at offset + 7 - - // Make sure we have space for MAC - // The buffer should already have been sized appropriately - - // Encrypt the message in place - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + msg.payload_size, - 4 + msg.payload_size + frame_footer_size_); - - int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); - APIError aerr = - handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); + uint16_t encrypted_len; + APIError aerr = this->encrypt_noise_message_(buf_start, msg.payload_size, msg.message_type, encrypted_len); if (aerr != APIError::OK) return aerr; - - // Fill in the encrypted size - buf_start[1] = static_cast(mbuf.size >> 8); - buf_start[2] = static_cast(mbuf.size); - - // Add iovec for this encrypted message - size_t msg_len = static_cast(3 + mbuf.size); // indicator + size + encrypted data - iovs.push_back({buf_start, msg_len}); - total_write_len += msg_len; + total_write_len += encrypted_len; } - // Send all encrypted messages in one writev call - return this->write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_buf_(write_start, total_write_len); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { @@ -519,16 +542,16 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { header[1] = (uint8_t) (len >> 8); header[2] = (uint8_t) len; + if (len == 0) { + return this->write_raw_buf_(header, 3); + } struct iovec iov[2]; iov[0].iov_base = header; iov[0].iov_len = 3; - if (len == 0) { - return this->write_raw_(iov, 1, 3); // Just header - } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_(iov, 2, 3 + len); // Header + data + return this->write_raw_iov_(iov, 2, 3 + len); } /** Initiate the data structures for the handshake. @@ -594,7 +617,7 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { if (aerr != APIError::OK) return aerr; - frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); + this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); HELPER_LOG("Handshake complete!"); noise_handshakestate_free(handshake_); diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index a6b17ff3b9..0676eab78d 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -9,25 +9,35 @@ namespace esphome::api { class APINoiseFrameHelper final : public APIFrameHelper { public: + // Noise header structure: + // Pos 0: indicator (0x01) + // Pos 1-2: encrypted payload size (16-bit big-endian) + // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) + // Pos 7+: actual payload data + static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len + APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx) : APIFrameHelper(std::move(socket)), ctx_(ctx) { - // Noise header structure: - // Pos 0: indicator (0x01) - // Pos 1-2: encrypted payload size (16-bit big-endian) - // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) - // Pos 7+: actual payload data - frame_header_padding_ = 7; + frame_header_padding_ = HEADER_PADDING; } ~APINoiseFrameHelper() override; APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: APIError state_action_(); + APIError state_action_client_hello_(); + APIError state_action_server_hello_(); + APIError state_action_handshake_(); + APIError state_action_handshake_read_(); + APIError state_action_handshake_write_(); APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); + APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, + uint16_t &encrypted_len_out); APIError init_handshake_(); APIError check_handshake_finished_(); void send_explicit_handshake_reject_(const LogString *reason); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9e669b31ee..fa611a6e33 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -39,15 +39,8 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; format_hex_pretty_to(hex_buf_, (buffer).data(), \ (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ } while (0) -#define LOG_PACKET_SENDING(data, len) \ - do { \ - char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ - ESP_LOGVV(TAG, "Sending raw: %s", \ - format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ - } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) -#define LOG_PACKET_SENDING(data, len) ((void) 0) #endif /// Initialize the frame helper, returns OK if successful. @@ -205,7 +198,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // Make sure to tell the remote that we don't // understand the indicator byte so it knows // we do not support it. - struct iovec iov[1]; // The \x00 first byte is the marker for plaintext. // // The remote will know how to handle the indicator byte, @@ -220,14 +212,12 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; char msg[INDICATOR_MSG_SIZE]; memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); - iov[0].iov_base = (void *) msg; + this->write_raw_buf_(msg, INDICATOR_MSG_SIZE); #else static const char MSG[] = "\x00" "Bad indicator byte"; - iov[0].iov_base = (void *) MSG; + this->write_raw_buf_(MSG, INDICATOR_MSG_SIZE); #endif - iov[0].iov_len = INDICATOR_MSG_SIZE; - this->write_raw_(iov, 1, INDICATOR_MSG_SIZE); } return aerr; } @@ -237,73 +227,101 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = this->rx_header_parsed_type_; return APIError::OK; } + +// Encode a 16-bit varint (1-3 bytes) using pre-computed length. +ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_t varint_len, uint8_t *p) { + if (varint_len >= 2) { + *p++ = static_cast(value | 0x80); + value >>= 7; + if (varint_len == 3) { + *p++ = static_cast(value | 0x80); + value >>= 7; + } + } + *p = static_cast(value); +} + +// Encode an 8-bit varint (1-2 bytes) using pre-computed length. +ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) { + if (varint_len == 2) { + *p++ = static_cast(value | 0x80); + *p = static_cast(value >> 7); + } else { + *p = value; + } +} + +// Write plaintext header into pre-allocated padding before payload. +// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg, +// actual header size for contiguous batch messages). +// Returns the total header length (indicator + varints). +ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size, + uint8_t message_type, uint8_t padding_size) { + uint8_t size_varint_len = ProtoSize::varint16(payload_size); + uint8_t type_varint_len = ProtoSize::varint8(message_type); + uint8_t total_header_len = 1 + size_varint_len + type_varint_len; + + // The header is right-justified within the padding so it sits immediately before payload. + // + // Single/first message (padding_size = HEADER_PADDING = 6): + // Example (small, header=3): [0-2] unused | [3] 0x00 | [4] size | [5] type | [6...] payload + // Example (medium, header=4): [0-1] unused | [2] 0x00 | [3-4] size | [5] type | [6...] payload + // Example (large, header=6): [0] 0x00 | [1-3] size | [4-5] type | [6...] payload + // + // Batch messages 2+ (padding_size = actual header size, no unused bytes): + // Example (small, header=3): [0] 0x00 | [1] size | [2] type | [3...] payload + // Example (medium, header=4): [0] 0x00 | [1-2] size | [3] type | [4...] payload +#ifdef ESPHOME_DEBUG_API + assert(padding_size >= total_header_len); +#endif + uint32_t header_offset = padding_size - total_header_len; + + // Write the plaintext header + buf_start[header_offset] = 0x00; // indicator + + // Encode varints directly into buffer using pre-computed lengths + encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1); + encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len); + + return total_header_len; +} + +APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); +#endif + + uint16_t payload_size = static_cast(buffer.get_buffer()->size() - HEADER_PADDING); + uint8_t *buffer_data = buffer.get_buffer()->data(); + uint8_t header_len = write_plaintext_header(buffer_data, payload_size, type, HEADER_PADDING); + return this->write_raw_fast_buf_(buffer_data + HEADER_PADDING - header_len, + static_cast(header_len + payload_size)); +} + APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = this->check_data_state_(); - if (aerr != APIError::OK) - return aerr; - - if (messages.empty()) { - return APIError::OK; - } - +#ifdef ESPHOME_DEBUG_API + assert(this->state_ == State::DATA); + assert(!messages.empty()); +#endif uint8_t *buffer_data = buffer.get_buffer()->data(); - // Stack-allocated iovec array - no heap allocation - StaticVector iovs; - uint16_t total_write_len = 0; + // First message has max padding (header_size = HEADER_PADDING), may have unused leading bytes. + // Subsequent messages were encoded with exact header sizes (header_size = actual header len). + // write_plaintext_header right-justifies the header within header_size bytes of padding. + const auto &first = messages[0]; + uint8_t *first_start = buffer_data + first.offset; + uint8_t header_len = write_plaintext_header(first_start, first.payload_size, first.message_type, HEADER_PADDING); + uint8_t *write_start = first_start + HEADER_PADDING - header_len; + uint16_t total_len = header_len + first.payload_size; - for (const auto &msg : messages) { - // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead - uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE - ? 1 - : (msg.payload_size < ProtoSize::VARINT_THRESHOLD_2_BYTE ? 2 : 3); - uint8_t type_varint_len = msg.message_type < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 : 2; - uint8_t total_header_len = 1 + size_varint_len + type_varint_len; - - // Calculate where to start writing the header - // The header starts at the latest possible position to minimize unused padding - // - // Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3 - // [0-2] - Unused padding - // [3] - 0x00 indicator byte - // [4] - Payload size varint (1 byte, for sizes 0-127) - // [5] - Message type varint (1 byte, for types 0-127) - // [6...] - Actual payload data - // - // Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2 - // [0-1] - Unused padding - // [2] - 0x00 indicator byte - // [3-4] - Payload size varint (2 bytes, for sizes 128-16383) - // [5] - Message type varint (1 byte, for types 0-127) - // [6...] - Actual payload data - // - // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 - // [0] - 0x00 indicator byte - // [1-3] - Payload size varint (3 bytes, for sizes 16384-65535) - // [4-5] - Message type varint (2 bytes, for types 128-16383) - // [6...] - Actual payload data - // - // The message starts at offset + frame_header_padding_ - // So we write the header starting at offset + frame_header_padding_ - total_header_len - uint8_t *buf_start = buffer_data + msg.offset; - uint32_t header_offset = frame_header_padding_ - total_header_len; - - // Write the plaintext header - buf_start[header_offset] = 0x00; // indicator - - // Encode varints directly into buffer - encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1); - encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len); - - // Add iovec for this message (header + payload) - size_t msg_len = static_cast(total_header_len + msg.payload_size); - iovs.push_back({buf_start + header_offset, msg_len}); - total_write_len += msg_len; + for (size_t i = 1; i < messages.size(); i++) { + const auto &msg = messages[i]; + header_len = write_plaintext_header(buffer_data + msg.offset, msg.payload_size, msg.message_type, msg.header_size); + total_len += header_len + msg.payload_size; } - // Send all messages in one writev call - return write_raw_(iovs.data(), iovs.size(), total_write_len); + return this->write_raw_fast_buf_(write_start, total_len); } } // namespace esphome::api diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index f8161c039d..8314754715 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -7,18 +7,21 @@ namespace esphome::api { class APIPlaintextFrameHelper final : public APIFrameHelper { public: + // Plaintext header structure (worst case): + // Pos 0: indicator (0x00) + // Pos 1-3: payload size varint (up to 3 bytes) + // Pos 4-5: message type varint (up to 2 bytes) + // Pos 6+: actual payload data + static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint + explicit APIPlaintextFrameHelper(std::unique_ptr socket) : APIFrameHelper(std::move(socket)) { - // Plaintext header structure (worst case): - // Pos 0: indicator (0x00) - // Pos 1-3: payload size varint (up to 3 bytes) - // Pos 4-5: message type varint (up to 2 bytes) - // Pos 6+: actual payload data - frame_header_padding_ = 6; + frame_header_padding_ = HEADER_PADDING; } ~APIPlaintextFrameHelper() override = default; APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 02600f0977..0f71268d70 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -96,4 +96,16 @@ extend google.protobuf.FieldOptions { // variant of the calc_ method. Use on fields that are almost always non-default // to eliminate dead branches on hot paths. optional bool force = 50016 [default=false]; + + // max_value: Maximum value a field can have. + // When max_value < 128, the code generator emits constant-size calculations + // and direct byte writes instead of varint branching, since the encoded varint + // is guaranteed to be 1 byte. + optional uint32 max_value = 50017; + + // max_data_length: Maximum length of a string or bytes field. + // When max_data_length < 128, the code generator emits constant-size + // length varint calculations and direct byte writes, since the length + // varint is guaranteed to be 1 byte. + optional uint32 max_data_length = 50018; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ae2cd2bae8..d27cfa57cf 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -31,133 +31,143 @@ bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) } return true; } -void HelloResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->api_version_major); - buffer.encode_uint32(2, this->api_version_minor); - buffer.encode_string(3, this->server_info); - buffer.encode_string(4, this->name); +uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); + return pos; } uint32_t HelloResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->api_version_major); size += ProtoSize::calc_uint32(1, this->api_version_minor); - size += ProtoSize::calc_length(1, this->server_info.size()); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->server_info.size(); + size += 2 + this->name.size(); return size; } #ifdef USE_AREAS -void AreaInfo::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->area_id); - buffer.encode_string(2, this->name); +uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + return pos; } uint32_t AreaInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->area_id); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); return size; } #endif #ifdef USE_DEVICES -void DeviceInfo::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->device_id); - buffer.encode_string(2, this->name); - buffer.encode_uint32(3, this->area_id); +uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); + return pos; } uint32_t DeviceInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->device_id); - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_uint32(1, this->area_id); return size; } #endif #ifdef USE_SERIAL_PROXY -void SerialProxyInfo::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->name); - buffer.encode_uint32(2, static_cast(this->port_type)); +uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); + return pos; } uint32_t SerialProxyInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->name.size()); - size += ProtoSize::calc_uint32(1, static_cast(this->port_type)); + size += this->port_type ? 2 : 0; return size; } #endif -void DeviceInfoResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(2, this->name); - buffer.encode_string(3, this->mac_address); - buffer.encode_string(4, this->esphome_version); - buffer.encode_string(5, this->compilation_time); - buffer.encode_string(6, this->model); +uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); #ifdef USE_DEEP_SLEEP - buffer.encode_bool(7, this->has_deep_sleep); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - buffer.encode_string(8, this->project_name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); #endif #ifdef ESPHOME_PROJECT_NAME - buffer.encode_string(9, this->project_version); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); #endif #ifdef USE_WEBSERVER - buffer.encode_uint32(10, this->webserver_port); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - buffer.encode_uint32(15, this->bluetooth_proxy_feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); #endif - buffer.encode_string(12, this->manufacturer); - buffer.encode_string(13, this->friendly_name); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); #ifdef USE_VOICE_ASSISTANT - buffer.encode_uint32(17, this->voice_assistant_feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - buffer.encode_string(16, this->suggested_area); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area, true); #endif #ifdef USE_BLUETOOTH_PROXY - buffer.encode_string(18, this->bluetooth_mac_address); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); #endif #ifdef USE_API_NOISE - buffer.encode_bool(19, this->api_encryption_supported); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); #endif #ifdef USE_DEVICES for (const auto &it : this->devices) { - buffer.encode_sub_message(20, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); } #endif #ifdef USE_AREAS for (const auto &it : this->areas) { - buffer.encode_sub_message(21, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); } #endif #ifdef USE_AREAS - buffer.encode_optional_sub_message(22, this->area); + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, this->area); #endif #ifdef USE_ZWAVE_PROXY - buffer.encode_uint32(23, this->zwave_proxy_feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, this->zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - buffer.encode_uint32(24, this->zwave_home_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, this->zwave_home_id); #endif #ifdef USE_SERIAL_PROXY for (const auto &it : this->serial_proxies) { - buffer.encode_sub_message(25, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } #endif + return pos; } uint32_t DeviceInfoResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += ProtoSize::calc_length(1, this->mac_address.size()); - size += ProtoSize::calc_length(1, this->esphome_version.size()); - size += ProtoSize::calc_length(1, this->compilation_time.size()); - size += ProtoSize::calc_length(1, this->model.size()); + size += 2 + this->name.size(); + size += 2 + this->mac_address.size(); + size += 2 + this->esphome_version.size(); + size += 2 + this->compilation_time.size(); + size += 2 + this->model.size(); #ifdef USE_DEEP_SLEEP size += ProtoSize::calc_bool(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size += ProtoSize::calc_length(1, this->project_name.size()); + size += 2 + this->project_name.size(); #endif #ifdef ESPHOME_PROJECT_NAME - size += ProtoSize::calc_length(1, this->project_version.size()); + size += 2 + this->project_version.size(); #endif #ifdef USE_WEBSERVER size += ProtoSize::calc_uint32(1, this->webserver_port); @@ -165,16 +175,16 @@ uint32_t DeviceInfoResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY size += ProtoSize::calc_uint32(1, this->bluetooth_proxy_feature_flags); #endif - size += ProtoSize::calc_length(1, this->manufacturer.size()); - size += ProtoSize::calc_length(1, this->friendly_name.size()); + size += 2 + this->manufacturer.size(); + size += 2 + this->friendly_name.size(); #ifdef USE_VOICE_ASSISTANT size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size += ProtoSize::calc_length(2, this->suggested_area.size()); + size += 3 + this->suggested_area.size(); #endif #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_length(2, this->bluetooth_mac_address.size()); + size += 3 + this->bluetooth_mac_address.size(); #endif #ifdef USE_API_NOISE size += ProtoSize::calc_bool(2, this->api_encryption_supported); @@ -206,45 +216,49 @@ uint32_t DeviceInfoResponse::calculate_size() const { return size; } #ifdef USE_BINARY_SENSOR -void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); - buffer.encode_string(5, this->device_class); - buffer.encode_bool(6, this->is_status_binary_sensor); - buffer.encode_bool(7, this->disabled_by_default); +uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->is_status_binary_sensor); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); #ifdef USE_ENTITY_ICON - buffer.encode_string(8, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->icon); #endif - buffer.encode_uint32(9, static_cast(this->entity_category)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(10, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif + return pos; } uint32_t ListEntitiesBinarySensorResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += 2 + this->name.size(); + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; size += ProtoSize::calc_bool(1, this->is_status_binary_sensor); size += ProtoSize::calc_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void BinarySensorStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->state); - buffer.encode_bool(3, this->missing_state); +uint8_t *BinarySensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t BinarySensorStateResponse::calculate_size() const { uint32_t size = 0; @@ -258,59 +272,63 @@ uint32_t BinarySensorStateResponse::calculate_size() const { } #endif #ifdef USE_COVER -void ListEntitiesCoverResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); - buffer.encode_bool(5, this->assumed_state); - buffer.encode_bool(6, this->supports_position); - buffer.encode_bool(7, this->supports_tilt); - buffer.encode_string(8, this->device_class); - buffer.encode_bool(9, this->disabled_by_default); +uint8_t *ListEntitiesCoverResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->assumed_state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_position); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_tilt); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); #ifdef USE_ENTITY_ICON - buffer.encode_string(10, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); #endif - buffer.encode_uint32(11, static_cast(this->entity_category)); - buffer.encode_bool(12, this->supports_stop); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_stop); #ifdef USE_DEVICES - buffer.encode_uint32(13, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); #endif + return pos; } uint32_t ListEntitiesCoverResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_bool(1, this->assumed_state); size += ProtoSize::calc_bool(1, this->supports_position); size += ProtoSize::calc_bool(1, this->supports_tilt); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; size += ProtoSize::calc_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; size += ProtoSize::calc_bool(1, this->supports_stop); #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void CoverStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_float(3, this->position); - buffer.encode_float(4, this->tilt); - buffer.encode_uint32(5, static_cast(this->current_operation)); +uint8_t *CoverStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->position); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->tilt); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->current_operation)); #ifdef USE_DEVICES - buffer.encode_uint32(6, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); #endif + return pos; } uint32_t CoverStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->position); size += ProtoSize::calc_float(1, this->tilt); - size += ProtoSize::calc_uint32(1, static_cast(this->current_operation)); + size += this->current_operation ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -355,40 +373,42 @@ bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_FAN -void ListEntitiesFanResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); - buffer.encode_bool(5, this->supports_oscillation); - buffer.encode_bool(6, this->supports_speed); - buffer.encode_bool(7, this->supports_direction); - buffer.encode_int32(8, this->supported_speed_count); - buffer.encode_bool(9, this->disabled_by_default); +uint8_t *ListEntitiesFanResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_oscillation); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_speed); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_direction); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_speed_count); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); #ifdef USE_ENTITY_ICON - buffer.encode_string(10, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); #endif - buffer.encode_uint32(11, static_cast(this->entity_category)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); for (const char *it : *this->supported_preset_modes) { - buffer.encode_string(12, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 12, it, strlen(it), true); } #ifdef USE_DEVICES - buffer.encode_uint32(13, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); #endif + return pos; } uint32_t ListEntitiesFanResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_bool(1, this->supports_oscillation); size += ProtoSize::calc_bool(1, this->supports_speed); size += ProtoSize::calc_bool(1, this->supports_direction); size += ProtoSize::calc_int32(1, this->supported_speed_count); size += ProtoSize::calc_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; if (!this->supported_preset_modes->empty()) { for (const char *it : *this->supported_preset_modes) { size += ProtoSize::calc_length_force(1, strlen(it)); @@ -399,23 +419,25 @@ uint32_t ListEntitiesFanResponse::calculate_size() const { #endif return size; } -void FanStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->state); - buffer.encode_bool(3, this->oscillating); - buffer.encode_uint32(5, static_cast(this->direction)); - buffer.encode_int32(6, this->speed_level); - buffer.encode_string(7, this->preset_mode); +uint8_t *FanStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->oscillating); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->direction)); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->speed_level); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->preset_mode); #ifdef USE_DEVICES - buffer.encode_uint32(8, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif + return pos; } uint32_t FanStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_bool(1, this->state); size += ProtoSize::calc_bool(1, this->oscillating); - size += ProtoSize::calc_uint32(1, static_cast(this->direction)); + size += this->direction ? 2 : 0; size += ProtoSize::calc_int32(1, this->speed_level); size += ProtoSize::calc_length(1, this->preset_mode.size()); #ifdef USE_DEVICES @@ -485,36 +507,36 @@ bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_LIGHT -void ListEntitiesLightResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesLightResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); for (const auto &it : *this->supported_color_modes) { - buffer.encode_uint32(12, static_cast(it), true); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it), true); } - buffer.encode_float(9, this->min_mireds); - buffer.encode_float(10, this->max_mireds); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->min_mireds); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->max_mireds); for (const char *it : *this->effects) { - buffer.encode_string(11, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it), true); } - buffer.encode_bool(13, this->disabled_by_default); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, this->disabled_by_default); #ifdef USE_ENTITY_ICON - buffer.encode_string(14, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, this->icon); #endif - buffer.encode_uint32(15, static_cast(this->entity_category)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(16, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); #endif + return pos; } uint32_t ListEntitiesLightResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); if (!this->supported_color_modes->empty()) { - for (const auto &it : *this->supported_color_modes) { - size += ProtoSize::calc_uint32_force(1, static_cast(it)); - } + size += this->supported_color_modes->size() * 2; } size += ProtoSize::calc_float(1, this->min_mireds); size += ProtoSize::calc_float(1, this->max_mireds); @@ -525,38 +547,40 @@ uint32_t ListEntitiesLightResponse::calculate_size() const { } size += ProtoSize::calc_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(2, this->device_id); #endif return size; } -void LightStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->state); - buffer.encode_float(3, this->brightness); - buffer.encode_uint32(11, static_cast(this->color_mode)); - buffer.encode_float(10, this->color_brightness); - buffer.encode_float(4, this->red); - buffer.encode_float(5, this->green); - buffer.encode_float(6, this->blue); - buffer.encode_float(7, this->white); - buffer.encode_float(8, this->color_temperature); - buffer.encode_float(12, this->cold_white); - buffer.encode_float(13, this->warm_white); - buffer.encode_string(9, this->effect); +uint8_t *LightStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->brightness); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->color_mode)); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->color_brightness); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->red); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->green); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->blue); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->white); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->color_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 12, this->cold_white); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 13, this->warm_white); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->effect); #ifdef USE_DEVICES - buffer.encode_uint32(14, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); #endif + return pos; } uint32_t LightStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_bool(1, this->state); size += ProtoSize::calc_float(1, this->brightness); - size += ProtoSize::calc_uint32(1, static_cast(this->color_mode)); + size += this->color_mode ? 2 : 0; size += ProtoSize::calc_float(1, this->color_brightness); size += ProtoSize::calc_float(1, this->red); size += ProtoSize::calc_float(1, this->green); @@ -681,51 +705,55 @@ bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_SENSOR -void ListEntitiesSensorResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_string(6, this->unit_of_measurement); - buffer.encode_int32(7, this->accuracy_decimals); - buffer.encode_bool(8, this->force_update); - buffer.encode_string(9, this->device_class); - buffer.encode_uint32(10, static_cast(this->state_class)); - buffer.encode_bool(12, this->disabled_by_default); - buffer.encode_uint32(13, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->unit_of_measurement); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->accuracy_decimals); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->force_update); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->state_class)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(14, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); #endif + return pos; } uint32_t ListEntitiesSensorResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif - size += ProtoSize::calc_length(1, this->unit_of_measurement.size()); + size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; size += ProtoSize::calc_int32(1, this->accuracy_decimals); size += ProtoSize::calc_bool(1, this->force_update); - size += ProtoSize::calc_length(1, this->device_class.size()); - size += ProtoSize::calc_uint32(1, static_cast(this->state_class)); + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += this->state_class ? 2 : 0; size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void SensorStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_float(2, this->state); - buffer.encode_bool(3, this->missing_state); +uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t SensorStateResponse::calculate_size() const { uint32_t size = 0; @@ -739,44 +767,48 @@ uint32_t SensorStateResponse::calculate_size() const { } #endif #ifdef USE_SWITCH -void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesSwitchResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->assumed_state); - buffer.encode_bool(7, this->disabled_by_default); - buffer.encode_uint32(8, static_cast(this->entity_category)); - buffer.encode_string(9, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->assumed_state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); #ifdef USE_DEVICES - buffer.encode_uint32(10, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif + return pos; } uint32_t ListEntitiesSwitchResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->assumed_state); size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void SwitchStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->state); +uint8_t *SwitchStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); #ifdef USE_DEVICES - buffer.encode_uint32(3, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif + return pos; } uint32_t SwitchStateResponse::calculate_size() const { uint32_t size = 0; @@ -814,43 +846,47 @@ bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_TEXT_SENSOR -void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_string(8, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); #ifdef USE_DEVICES - buffer.encode_uint32(9, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif + return pos; } uint32_t ListEntitiesTextSensorResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void TextSensorStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_string(2, this->state); - buffer.encode_bool(3, this->missing_state); +uint8_t *TextSensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t TextSensorStateResponse::calculate_size() const { uint32_t size = 0; @@ -876,13 +912,15 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t } return true; } -void SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, static_cast(this->level)); - buffer.encode_bytes(3, this->message_ptr_, this->message_len_); +uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level)); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_); + return pos; } uint32_t SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_uint32(1, static_cast(this->level)); + size += this->level ? 2 : 0; size += ProtoSize::calc_length(1, this->message_len_); return size; } @@ -899,7 +937,11 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD } return true; } -void NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer &buffer) const { buffer.encode_bool(1, this->success); } +uint8_t *NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + return pos; +} uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_bool(1, this->success); @@ -907,9 +949,11 @@ uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const { } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -void HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->key); - buffer.encode_string(2, this->value); +uint8_t *HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->key); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->value); + return pos; } uint32_t HomeassistantServiceMap::calculate_size() const { uint32_t size = 0; @@ -917,27 +961,29 @@ uint32_t HomeassistantServiceMap::calculate_size() const { size += ProtoSize::calc_length(1, this->value.size()); return size; } -void HomeassistantActionRequest::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->service); +uint8_t *HomeassistantActionRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->service); for (auto &it : this->data) { - buffer.encode_sub_message(2, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } for (auto &it : this->data_template) { - buffer.encode_sub_message(3, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } for (auto &it : this->variables) { - buffer.encode_sub_message(4, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - buffer.encode_bool(5, this->is_event); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - buffer.encode_uint32(6, this->call_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->call_id); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - buffer.encode_bool(7, this->wants_response); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->wants_response); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - buffer.encode_string(8, this->response_template); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->response_template); #endif + return pos; } uint32_t HomeassistantActionRequest::calculate_size() const { uint32_t size = 0; @@ -1004,10 +1050,12 @@ bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDe } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->entity_id); - buffer.encode_string(2, this->attribute); - buffer.encode_bool(3, this->once); +uint8_t *SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->entity_id); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->attribute); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->once); + return pos; } uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { uint32_t size = 0; @@ -1112,23 +1160,27 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } #ifdef USE_API_USER_DEFINED_ACTIONS -void ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->name); - buffer.encode_uint32(2, static_cast(this->type)); +uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); + return pos; } uint32_t ListEntitiesServicesArgument::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->name.size()); - size += ProtoSize::calc_uint32(1, static_cast(this->type)); + size += this->type ? 2 : 0; return size; } -void ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->name); - buffer.write_tag_and_fixed32(21, this->key); +uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); for (auto &it : this->args) { - buffer.encode_sub_message(3, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - buffer.encode_uint32(4, static_cast(this->supports_response)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); + return pos; } uint32_t ListEntitiesServicesResponse::calculate_size() const { uint32_t size = 0; @@ -1139,7 +1191,7 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, static_cast(this->supports_response)); + size += this->supports_response ? 2 : 0; return size; } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -1247,13 +1299,15 @@ void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { } #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES -void ExecuteServiceResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->call_id); - buffer.encode_bool(2, this->success); - buffer.encode_string(3, this->error_message); +uint8_t *ExecuteServiceResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->call_id); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error_message); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - buffer.encode_bytes(4, this->response_data, this->response_data_len); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, this->response_data, this->response_data_len); #endif + return pos; } uint32_t ExecuteServiceResponse::calculate_size() const { uint32_t size = 0; @@ -1267,41 +1321,45 @@ uint32_t ExecuteServiceResponse::calculate_size() const { } #endif #ifdef USE_CAMERA -void ListEntitiesCameraResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); - buffer.encode_bool(5, this->disabled_by_default); +uint8_t *ListEntitiesCameraResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); #ifdef USE_ENTITY_ICON - buffer.encode_string(6, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->icon); #endif - buffer.encode_uint32(7, static_cast(this->entity_category)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(8, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif + return pos; } uint32_t ListEntitiesCameraResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void CameraImageResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bytes(2, this->data_ptr_, this->data_len_); - buffer.encode_bool(3, this->done); +uint8_t *CameraImageResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->done); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t CameraImageResponse::calculate_size() const { uint32_t size = 0; @@ -1328,74 +1386,70 @@ bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t v } #endif #ifdef USE_CLIMATE -void ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); - buffer.encode_bool(5, this->supports_current_temperature); - buffer.encode_bool(6, this->supports_two_point_target_temperature); +uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_current_temperature); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_two_point_target_temperature); for (const auto &it : *this->supported_modes) { - buffer.encode_uint32(7, static_cast(it), true); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it), true); } - buffer.encode_float(8, this->visual_min_temperature); - buffer.encode_float(9, this->visual_max_temperature); - buffer.encode_float(10, this->visual_target_temperature_step); - buffer.encode_bool(12, this->supports_action); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->visual_min_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->visual_max_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->visual_target_temperature_step); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_action); for (const auto &it : *this->supported_fan_modes) { - buffer.encode_uint32(13, static_cast(it), true); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it), true); } for (const auto &it : *this->supported_swing_modes) { - buffer.encode_uint32(14, static_cast(it), true); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it), true); } for (const char *it : *this->supported_custom_fan_modes) { - buffer.encode_string(15, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it), true); } for (const auto &it : *this->supported_presets) { - buffer.encode_uint32(16, static_cast(it), true); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it), true); } for (const char *it : *this->supported_custom_presets) { - buffer.encode_string(17, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it), true); } - buffer.encode_bool(18, this->disabled_by_default); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, this->disabled_by_default); #ifdef USE_ENTITY_ICON - buffer.encode_string(19, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, this->icon); #endif - buffer.encode_uint32(20, static_cast(this->entity_category)); - buffer.encode_float(21, this->visual_current_temperature_step); - buffer.encode_bool(22, this->supports_current_humidity); - buffer.encode_bool(23, this->supports_target_humidity); - buffer.encode_float(24, this->visual_min_humidity); - buffer.encode_float(25, this->visual_max_humidity); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(this->entity_category)); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, this->visual_current_temperature_step); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, this->supports_current_humidity); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, this->supports_target_humidity); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, this->visual_min_humidity); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, this->visual_max_humidity); #ifdef USE_DEVICES - buffer.encode_uint32(26, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); #endif - buffer.encode_uint32(27, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); + return pos; } uint32_t ListEntitiesClimateResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); size += ProtoSize::calc_bool(1, this->supports_current_temperature); size += ProtoSize::calc_bool(1, this->supports_two_point_target_temperature); if (!this->supported_modes->empty()) { - for (const auto &it : *this->supported_modes) { - size += ProtoSize::calc_uint32_force(1, static_cast(it)); - } + size += this->supported_modes->size() * 2; } size += ProtoSize::calc_float(1, this->visual_min_temperature); size += ProtoSize::calc_float(1, this->visual_max_temperature); size += ProtoSize::calc_float(1, this->visual_target_temperature_step); size += ProtoSize::calc_bool(1, this->supports_action); if (!this->supported_fan_modes->empty()) { - for (const auto &it : *this->supported_fan_modes) { - size += ProtoSize::calc_uint32_force(1, static_cast(it)); - } + size += this->supported_fan_modes->size() * 2; } if (!this->supported_swing_modes->empty()) { - for (const auto &it : *this->supported_swing_modes) { - size += ProtoSize::calc_uint32_force(1, static_cast(it)); - } + size += this->supported_swing_modes->size() * 2; } if (!this->supported_custom_fan_modes->empty()) { for (const char *it : *this->supported_custom_fan_modes) { @@ -1403,9 +1457,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { } } if (!this->supported_presets->empty()) { - for (const auto &it : *this->supported_presets) { - size += ProtoSize::calc_uint32_force(2, static_cast(it)); - } + size += this->supported_presets->size() * 3; } if (!this->supported_custom_presets->empty()) { for (const char *it : *this->supported_custom_presets) { @@ -1414,9 +1466,9 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { } size += ProtoSize::calc_bool(2, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(2, this->icon.size()); + size += !this->icon.empty() ? 3 + this->icon.size() : 0; #endif - size += ProtoSize::calc_uint32(2, static_cast(this->entity_category)); + size += this->entity_category ? 3 : 0; size += ProtoSize::calc_float(2, this->visual_current_temperature_step); size += ProtoSize::calc_bool(2, this->supports_current_humidity); size += ProtoSize::calc_bool(2, this->supports_target_humidity); @@ -1428,38 +1480,40 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { size += ProtoSize::calc_uint32(2, this->feature_flags); return size; } -void ClimateStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_uint32(2, static_cast(this->mode)); - buffer.encode_float(3, this->current_temperature); - buffer.encode_float(4, this->target_temperature); - buffer.encode_float(5, this->target_temperature_low); - buffer.encode_float(6, this->target_temperature_high); - buffer.encode_uint32(8, static_cast(this->action)); - buffer.encode_uint32(9, static_cast(this->fan_mode)); - buffer.encode_uint32(10, static_cast(this->swing_mode)); - buffer.encode_string(11, this->custom_fan_mode); - buffer.encode_uint32(12, static_cast(this->preset)); - buffer.encode_string(13, this->custom_preset); - buffer.encode_float(14, this->current_humidity); - buffer.encode_float(15, this->target_humidity); +uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->current_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->target_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->target_temperature_low); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->target_temperature_high); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->action)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->fan_mode)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->swing_mode)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->custom_fan_mode); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->preset)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->custom_preset); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 14, this->current_humidity); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 15, this->target_humidity); #ifdef USE_DEVICES - buffer.encode_uint32(16, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); #endif + return pos; } uint32_t ClimateStateResponse::calculate_size() const { uint32_t size = 0; size += 5; - size += ProtoSize::calc_uint32(1, static_cast(this->mode)); + size += this->mode ? 2 : 0; size += ProtoSize::calc_float(1, this->current_temperature); size += ProtoSize::calc_float(1, this->target_temperature); size += ProtoSize::calc_float(1, this->target_temperature_low); size += ProtoSize::calc_float(1, this->target_temperature_high); - size += ProtoSize::calc_uint32(1, static_cast(this->action)); - size += ProtoSize::calc_uint32(1, static_cast(this->fan_mode)); - size += ProtoSize::calc_uint32(1, static_cast(this->swing_mode)); + size += this->action ? 2 : 0; + size += this->fan_mode ? 2 : 0; + size += this->swing_mode ? 2 : 0; size += ProtoSize::calc_length(1, this->custom_fan_mode.size()); - size += ProtoSize::calc_uint32(1, static_cast(this->preset)); + size += this->preset ? 2 : 0; size += ProtoSize::calc_length(1, this->custom_preset.size()); size += ProtoSize::calc_float(1, this->current_humidity); size += ProtoSize::calc_float(1, this->target_humidity); @@ -1561,36 +1615,38 @@ bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_WATER_HEATER -void ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(4, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); #endif - buffer.encode_bool(5, this->disabled_by_default); - buffer.encode_uint32(6, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(7, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); #endif - buffer.encode_float(8, this->min_temperature); - buffer.encode_float(9, this->max_temperature); - buffer.encode_float(10, this->target_temperature_step); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->target_temperature_step); for (const auto &it : *this->supported_modes) { - buffer.encode_uint32(11, static_cast(it), true); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); } - buffer.encode_uint32(12, this->supported_features); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); + return pos; } uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -1598,31 +1654,31 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { size += ProtoSize::calc_float(1, this->max_temperature); size += ProtoSize::calc_float(1, this->target_temperature_step); if (!this->supported_modes->empty()) { - for (const auto &it : *this->supported_modes) { - size += ProtoSize::calc_uint32_force(1, static_cast(it)); - } + size += this->supported_modes->size() * 2; } size += ProtoSize::calc_uint32(1, this->supported_features); return size; } -void WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_float(2, this->current_temperature); - buffer.encode_float(3, this->target_temperature); - buffer.encode_uint32(4, static_cast(this->mode)); +uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->current_temperature); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->target_temperature); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->mode)); #ifdef USE_DEVICES - buffer.encode_uint32(5, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); #endif - buffer.encode_uint32(6, this->state); - buffer.encode_float(7, this->target_temperature_low); - buffer.encode_float(8, this->target_temperature_high); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->state); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->target_temperature_low); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->target_temperature_high); + return pos; } uint32_t WaterHeaterStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->current_temperature); size += ProtoSize::calc_float(1, this->target_temperature); - size += ProtoSize::calc_uint32(1, static_cast(this->mode)); + size += this->mode ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -1673,53 +1729,57 @@ bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value } #endif #ifdef USE_NUMBER -void ListEntitiesNumberResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesNumberResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_float(6, this->min_value); - buffer.encode_float(7, this->max_value); - buffer.encode_float(8, this->step); - buffer.encode_bool(9, this->disabled_by_default); - buffer.encode_uint32(10, static_cast(this->entity_category)); - buffer.encode_string(11, this->unit_of_measurement); - buffer.encode_uint32(12, static_cast(this->mode)); - buffer.encode_string(13, this->device_class); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->min_value); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->max_value); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->step); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->unit_of_measurement); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->mode)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_class); #ifdef USE_DEVICES - buffer.encode_uint32(14, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); #endif + return pos; } uint32_t ListEntitiesNumberResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_float(1, this->min_value); size += ProtoSize::calc_float(1, this->max_value); size += ProtoSize::calc_float(1, this->step); size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->unit_of_measurement.size()); - size += ProtoSize::calc_uint32(1, static_cast(this->mode)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; + size += this->mode ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void NumberStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_float(2, this->state); - buffer.encode_bool(3, this->missing_state); +uint8_t *NumberStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t NumberStateResponse::calculate_size() const { uint32_t size = 0; @@ -1758,29 +1818,31 @@ bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_SELECT -void ListEntitiesSelectResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesSelectResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif for (const char *it : *this->options) { - buffer.encode_string(6, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it), true); } - buffer.encode_bool(7, this->disabled_by_default); - buffer.encode_uint32(8, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(9, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif + return pos; } uint32_t ListEntitiesSelectResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif if (!this->options->empty()) { for (const char *it : *this->options) { @@ -1788,19 +1850,21 @@ uint32_t ListEntitiesSelectResponse::calculate_size() const { } } size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void SelectStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_string(2, this->state); - buffer.encode_bool(3, this->missing_state); +uint8_t *SelectStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t SelectStateResponse::calculate_size() const { uint32_t size = 0; @@ -1847,31 +1911,33 @@ bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_SIREN -void ListEntitiesSirenResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesSirenResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); for (const char *it : *this->tones) { - buffer.encode_string(7, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it), true); } - buffer.encode_bool(8, this->supports_duration); - buffer.encode_bool(9, this->supports_volume); - buffer.encode_uint32(10, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_duration); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_volume); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(11, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); #endif + return pos; } uint32_t ListEntitiesSirenResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); if (!this->tones->empty()) { @@ -1881,18 +1947,20 @@ uint32_t ListEntitiesSirenResponse::calculate_size() const { } size += ProtoSize::calc_bool(1, this->supports_duration); size += ProtoSize::calc_bool(1, this->supports_volume); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void SirenStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->state); +uint8_t *SirenStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); #ifdef USE_DEVICES - buffer.encode_uint32(3, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif + return pos; } uint32_t SirenStateResponse::calculate_size() const { uint32_t size = 0; @@ -1959,33 +2027,35 @@ bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_LOCK -void ListEntitiesLockResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesLockResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_bool(8, this->assumed_state); - buffer.encode_bool(9, this->supports_open); - buffer.encode_bool(10, this->requires_code); - buffer.encode_string(11, this->code_format); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->assumed_state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_open); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->code_format); #ifdef USE_DEVICES - buffer.encode_uint32(12, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); #endif + return pos; } uint32_t ListEntitiesLockResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; size += ProtoSize::calc_bool(1, this->assumed_state); size += ProtoSize::calc_bool(1, this->supports_open); size += ProtoSize::calc_bool(1, this->requires_code); @@ -1995,17 +2065,19 @@ uint32_t ListEntitiesLockResponse::calculate_size() const { #endif return size; } -void LockStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_uint32(2, static_cast(this->state)); +uint8_t *LockStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); #ifdef USE_DEVICES - buffer.encode_uint32(3, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif + return pos; } uint32_t LockStateResponse::calculate_size() const { uint32_t size = 0; size += 5; - size += ProtoSize::calc_uint32(1, static_cast(this->state)); + size += this->state ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -2052,31 +2124,33 @@ bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_BUTTON -void ListEntitiesButtonResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesButtonResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_string(8, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); #ifdef USE_DEVICES - buffer.encode_uint32(9, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif + return pos; } uint32_t ListEntitiesButtonResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -2106,50 +2180,54 @@ bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_MEDIA_PLAYER -void MediaPlayerSupportedFormat::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->format); - buffer.encode_uint32(2, this->sample_rate); - buffer.encode_uint32(3, this->num_channels); - buffer.encode_uint32(4, static_cast(this->purpose)); - buffer.encode_uint32(5, this->sample_bytes); +uint8_t *MediaPlayerSupportedFormat::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->format); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->sample_rate); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->num_channels); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->purpose)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->sample_bytes); + return pos; } uint32_t MediaPlayerSupportedFormat::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->format.size()); size += ProtoSize::calc_uint32(1, this->sample_rate); size += ProtoSize::calc_uint32(1, this->num_channels); - size += ProtoSize::calc_uint32(1, static_cast(this->purpose)); + size += this->purpose ? 2 : 0; size += ProtoSize::calc_uint32(1, this->sample_bytes); return size; } -void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_bool(8, this->supports_pause); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause); for (auto &it : this->supported_formats) { - buffer.encode_sub_message(9, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); } #ifdef USE_DEVICES - buffer.encode_uint32(10, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif - buffer.encode_uint32(11, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->feature_flags); + return pos; } uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; size += ProtoSize::calc_bool(1, this->supports_pause); if (!this->supported_formats.empty()) { for (const auto &it : this->supported_formats) { @@ -2162,19 +2240,21 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, this->feature_flags); return size; } -void MediaPlayerStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_uint32(2, static_cast(this->state)); - buffer.encode_float(3, this->volume); - buffer.encode_bool(4, this->muted); +uint8_t *MediaPlayerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->muted); #ifdef USE_DEVICES - buffer.encode_uint32(5, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); #endif + return pos; } uint32_t MediaPlayerStateResponse::calculate_size() const { uint32_t size = 0; size += 5; - size += ProtoSize::calc_uint32(1, static_cast(this->state)); + size += this->state ? 2 : 0; size += ProtoSize::calc_float(1, this->volume); size += ProtoSize::calc_bool(1, this->muted); #ifdef USE_DEVICES @@ -2248,28 +2328,35 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer) const { - buffer.write_raw_byte(8); - buffer.encode_varint_raw_64(this->address); - buffer.write_raw_byte(16); - buffer.encode_varint_raw(encode_zigzag32(this->rssi)); - buffer.encode_uint32(3, this->address_type); - buffer.write_raw_byte(34); - buffer.encode_varint_raw(this->data_len); - buffer.encode_raw(this->data, this->data_len); +uint8_t *BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, this->address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(this->rssi)); + if (this->address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(this->data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data, this->data_len); + return pos; } uint32_t BluetoothLERawAdvertisement::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint64_force(1, this->address); size += ProtoSize::calc_sint32_force(1, this->rssi); - size += ProtoSize::calc_uint32(1, this->address_type); - size += ProtoSize::calc_length_force(1, this->data_len); + size += this->address_type ? 2 : 0; + size += 2 + this->data_len; return size; } -void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer) const { +uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { - buffer.encode_sub_message(1, this->advertisements[i]); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->advertisements[i]); } + return pos; } uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; @@ -2297,11 +2384,13 @@ bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value } return true; } -void BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_bool(2, this->connected); - buffer.encode_uint32(3, this->mtu); - buffer.encode_int32(4, this->error); +uint8_t *BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->connected); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mtu); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error); + return pos; } uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { uint32_t size = 0; @@ -2321,13 +2410,15 @@ bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_var } return true; } -void BluetoothGATTDescriptor::encode(ProtoWriteBuffer &buffer) const { +uint8_t *BluetoothGATTDescriptor::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); if (this->uuid[0] != 0 || this->uuid[1] != 0) { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); } - buffer.encode_uint32(2, this->handle); - buffer.encode_uint32(3, this->short_uuid); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->short_uuid); + return pos; } uint32_t BluetoothGATTDescriptor::calculate_size() const { uint32_t size = 0; @@ -2339,17 +2430,19 @@ uint32_t BluetoothGATTDescriptor::calculate_size() const { size += ProtoSize::calc_uint32(1, this->short_uuid); return size; } -void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer &buffer) const { +uint8_t *BluetoothGATTCharacteristic::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); if (this->uuid[0] != 0 || this->uuid[1] != 0) { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); } - buffer.encode_uint32(2, this->handle); - buffer.encode_uint32(3, this->properties); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->properties); for (auto &it : this->descriptors) { - buffer.encode_sub_message(4, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - buffer.encode_uint32(5, this->short_uuid); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->short_uuid); + return pos; } uint32_t BluetoothGATTCharacteristic::calculate_size() const { uint32_t size = 0; @@ -2367,16 +2460,18 @@ uint32_t BluetoothGATTCharacteristic::calculate_size() const { size += ProtoSize::calc_uint32(1, this->short_uuid); return size; } -void BluetoothGATTService::encode(ProtoWriteBuffer &buffer) const { +uint8_t *BluetoothGATTService::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); if (this->uuid[0] != 0 || this->uuid[1] != 0) { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); } - buffer.encode_uint32(2, this->handle); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); for (auto &it : this->characteristics) { - buffer.encode_sub_message(3, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - buffer.encode_uint32(4, this->short_uuid); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->short_uuid); + return pos; } uint32_t BluetoothGATTService::calculate_size() const { uint32_t size = 0; @@ -2393,11 +2488,13 @@ uint32_t BluetoothGATTService::calculate_size() const { size += ProtoSize::calc_uint32(1, this->short_uuid); return size; } -void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); +uint8_t *BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); for (auto &it : this->services) { - buffer.encode_sub_message(2, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } + return pos; } uint32_t BluetoothGATTGetServicesResponse::calculate_size() const { uint32_t size = 0; @@ -2409,8 +2506,10 @@ uint32_t BluetoothGATTGetServicesResponse::calculate_size() const { } return size; } -void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); +uint8_t *BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + return pos; } uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { uint32_t size = 0; @@ -2430,10 +2529,12 @@ bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_val } return true; } -void BluetoothGATTReadResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_uint32(2, this->handle); - buffer.encode_bytes(3, this->data_ptr_, this->data_len_); +uint8_t *BluetoothGATTReadResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + return pos; } uint32_t BluetoothGATTReadResponse::calculate_size() const { uint32_t size = 0; @@ -2524,10 +2625,12 @@ bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_v } return true; } -void BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_uint32(2, this->handle); - buffer.encode_bytes(3, this->data_ptr_, this->data_len_); +uint8_t *BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + return pos; } uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { uint32_t size = 0; @@ -2536,14 +2639,16 @@ uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len_); return size; } -void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->free); - buffer.encode_uint32(2, this->limit); +uint8_t *BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->free); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->limit); for (const auto &it : this->allocated) { if (it != 0) { - buffer.encode_uint64(3, it, true); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); } } + return pos; } uint32_t BluetoothConnectionsFreeResponse::calculate_size() const { uint32_t size = 0; @@ -2556,10 +2661,12 @@ uint32_t BluetoothConnectionsFreeResponse::calculate_size() const { } return size; } -void BluetoothGATTErrorResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_uint32(2, this->handle); - buffer.encode_int32(3, this->error); +uint8_t *BluetoothGATTErrorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + return pos; } uint32_t BluetoothGATTErrorResponse::calculate_size() const { uint32_t size = 0; @@ -2568,9 +2675,11 @@ uint32_t BluetoothGATTErrorResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } -void BluetoothGATTWriteResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_uint32(2, this->handle); +uint8_t *BluetoothGATTWriteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + return pos; } uint32_t BluetoothGATTWriteResponse::calculate_size() const { uint32_t size = 0; @@ -2578,9 +2687,11 @@ uint32_t BluetoothGATTWriteResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, this->handle); return size; } -void BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_uint32(2, this->handle); +uint8_t *BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + return pos; } uint32_t BluetoothGATTNotifyResponse::calculate_size() const { uint32_t size = 0; @@ -2588,10 +2699,12 @@ uint32_t BluetoothGATTNotifyResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, this->handle); return size; } -void BluetoothDevicePairingResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_bool(2, this->paired); - buffer.encode_int32(3, this->error); +uint8_t *BluetoothDevicePairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->paired); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + return pos; } uint32_t BluetoothDevicePairingResponse::calculate_size() const { uint32_t size = 0; @@ -2600,10 +2713,12 @@ uint32_t BluetoothDevicePairingResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } -void BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_bool(2, this->success); - buffer.encode_int32(3, this->error); +uint8_t *BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + return pos; } uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { uint32_t size = 0; @@ -2612,10 +2727,12 @@ uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } -void BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_bool(2, this->success); - buffer.encode_int32(3, this->error); +uint8_t *BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + return pos; } uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { uint32_t size = 0; @@ -2624,16 +2741,18 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } -void BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, static_cast(this->state)); - buffer.encode_uint32(2, static_cast(this->mode)); - buffer.encode_uint32(3, static_cast(this->configured_mode)); +uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->configured_mode)); + return pos; } uint32_t BluetoothScannerStateResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_uint32(1, static_cast(this->state)); - size += ProtoSize::calc_uint32(1, static_cast(this->mode)); - size += ProtoSize::calc_uint32(1, static_cast(this->configured_mode)); + size += this->state ? 2 : 0; + size += this->mode ? 2 : 0; + size += this->configured_mode ? 2 : 0; return size; } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -2661,10 +2780,12 @@ bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_vari } return true; } -void VoiceAssistantAudioSettings::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->noise_suppression_level); - buffer.encode_uint32(2, this->auto_gain); - buffer.encode_float(3, this->volume_multiplier); +uint8_t *VoiceAssistantAudioSettings::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->noise_suppression_level); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->auto_gain); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume_multiplier); + return pos; } uint32_t VoiceAssistantAudioSettings::calculate_size() const { uint32_t size = 0; @@ -2673,12 +2794,14 @@ uint32_t VoiceAssistantAudioSettings::calculate_size() const { size += ProtoSize::calc_float(1, this->volume_multiplier); return size; } -void VoiceAssistantRequest::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_bool(1, this->start); - buffer.encode_string(2, this->conversation_id); - buffer.encode_uint32(3, this->flags); - buffer.encode_optional_sub_message(4, this->audio_settings); - buffer.encode_string(5, this->wake_word_phrase); +uint8_t *VoiceAssistantRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->start); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->conversation_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->flags); + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, this->audio_settings); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->wake_word_phrase); + return pos; } uint32_t VoiceAssistantRequest::calculate_size() const { uint32_t size = 0; @@ -2760,9 +2883,11 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited } return true; } -void VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_bytes(1, this->data, this->data_len); - buffer.encode_bool(2, this->end); +uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); + return pos; } uint32_t VoiceAssistantAudio::calculate_size() const { uint32_t size = 0; @@ -2833,18 +2958,24 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength } return true; } -void VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer &buffer) const { buffer.encode_bool(1, this->success); } +uint8_t *VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + return pos; +} uint32_t VoiceAssistantAnnounceFinished::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_bool(1, this->success); return size; } -void VoiceAssistantWakeWord::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->id); - buffer.encode_string(2, this->wake_word); +uint8_t *VoiceAssistantWakeWord::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->id); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->wake_word); for (auto &it : this->trained_languages) { - buffer.encode_string(3, it, true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); } + return pos; } uint32_t VoiceAssistantWakeWord::calculate_size() const { uint32_t size = 0; @@ -2908,14 +3039,16 @@ bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoL } return true; } -void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer) const { +uint8_t *VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); for (auto &it : this->available_wake_words) { - buffer.encode_sub_message(1, it); + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); } for (const auto &it : *this->active_wake_words) { - buffer.encode_string(2, it, true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, it, true); } - buffer.encode_uint32(3, this->max_active_wake_words); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->max_active_wake_words); + return pos; } uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { uint32_t size = 0; @@ -2944,32 +3077,34 @@ bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengt } #endif #ifdef USE_ALARM_CONTROL_PANEL -void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_uint32(8, this->supported_features); - buffer.encode_bool(9, this->requires_code); - buffer.encode_bool(10, this->requires_code_to_arm); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_features); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->requires_code); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code_to_arm); #ifdef USE_DEVICES - buffer.encode_uint32(11, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); #endif + return pos; } uint32_t ListEntitiesAlarmControlPanelResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; size += ProtoSize::calc_uint32(1, this->supported_features); size += ProtoSize::calc_bool(1, this->requires_code); size += ProtoSize::calc_bool(1, this->requires_code_to_arm); @@ -2978,17 +3113,19 @@ uint32_t ListEntitiesAlarmControlPanelResponse::calculate_size() const { #endif return size; } -void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_uint32(2, static_cast(this->state)); +uint8_t *AlarmControlPanelStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); #ifdef USE_DEVICES - buffer.encode_uint32(3, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif + return pos; } uint32_t AlarmControlPanelStateResponse::calculate_size() const { uint32_t size = 0; size += 5; - size += ProtoSize::calc_uint32(1, static_cast(this->state)); + size += this->state ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -3032,49 +3169,53 @@ bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit } #endif #ifdef USE_TEXT -void ListEntitiesTextResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesTextResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_uint32(8, this->min_length); - buffer.encode_uint32(9, this->max_length); - buffer.encode_string(10, this->pattern); - buffer.encode_uint32(11, static_cast(this->mode)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_length); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_length); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->pattern); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->mode)); #ifdef USE_DEVICES - buffer.encode_uint32(12, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); #endif + return pos; } uint32_t ListEntitiesTextResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; size += ProtoSize::calc_uint32(1, this->min_length); size += ProtoSize::calc_uint32(1, this->max_length); size += ProtoSize::calc_length(1, this->pattern.size()); - size += ProtoSize::calc_uint32(1, static_cast(this->mode)); + size += this->mode ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void TextStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_string(2, this->state); - buffer.encode_bool(3, this->missing_state); +uint8_t *TextStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t TextStateResponse::calculate_size() const { uint32_t size = 0; @@ -3121,43 +3262,47 @@ bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_DATETIME_DATE -void ListEntitiesDateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesDateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(8, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif + return pos; } uint32_t ListEntitiesDateResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void DateStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->missing_state); - buffer.encode_uint32(3, this->year); - buffer.encode_uint32(4, this->month); - buffer.encode_uint32(5, this->day); +uint8_t *DateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->year); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->month); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->day); #ifdef USE_DEVICES - buffer.encode_uint32(6, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); #endif + return pos; } uint32_t DateStateResponse::calculate_size() const { uint32_t size = 0; @@ -3204,43 +3349,47 @@ bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_DATETIME_TIME -void ListEntitiesTimeResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(8, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif + return pos; } uint32_t ListEntitiesTimeResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void TimeStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->missing_state); - buffer.encode_uint32(3, this->hour); - buffer.encode_uint32(4, this->minute); - buffer.encode_uint32(5, this->second); +uint8_t *TimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->hour); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->minute); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->second); #ifdef USE_DEVICES - buffer.encode_uint32(6, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); #endif + return pos; } uint32_t TimeStateResponse::calculate_size() const { uint32_t size = 0; @@ -3287,34 +3436,36 @@ bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_EVENT -void ListEntitiesEventResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesEventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_string(8, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); for (const char *it : *this->event_types) { - buffer.encode_string(9, it, strlen(it), true); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it), true); } #ifdef USE_DEVICES - buffer.encode_uint32(10, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif + return pos; } uint32_t ListEntitiesEventResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; if (!this->event_types->empty()) { for (const char *it : *this->event_types) { size += ProtoSize::calc_length_force(1, strlen(it)); @@ -3325,12 +3476,14 @@ uint32_t ListEntitiesEventResponse::calculate_size() const { #endif return size; } -void EventResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_string(2, this->event_type); +uint8_t *EventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->event_type); #ifdef USE_DEVICES - buffer.encode_uint32(3, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif + return pos; } uint32_t EventResponse::calculate_size() const { uint32_t size = 0; @@ -3343,34 +3496,36 @@ uint32_t EventResponse::calculate_size() const { } #endif #ifdef USE_VALVE -void ListEntitiesValveResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesValveResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_string(8, this->device_class); - buffer.encode_bool(9, this->assumed_state); - buffer.encode_bool(10, this->supports_position); - buffer.encode_bool(11, this->supports_stop); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->assumed_state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->supports_position); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supports_stop); #ifdef USE_DEVICES - buffer.encode_uint32(12, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); #endif + return pos; } uint32_t ListEntitiesValveResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; size += ProtoSize::calc_bool(1, this->assumed_state); size += ProtoSize::calc_bool(1, this->supports_position); size += ProtoSize::calc_bool(1, this->supports_stop); @@ -3379,19 +3534,21 @@ uint32_t ListEntitiesValveResponse::calculate_size() const { #endif return size; } -void ValveStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_float(2, this->position); - buffer.encode_uint32(3, static_cast(this->current_operation)); +uint8_t *ValveStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->position); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->current_operation)); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t ValveStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->position); - size += ProtoSize::calc_uint32(1, static_cast(this->current_operation)); + size += this->current_operation ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -3430,41 +3587,45 @@ bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_DATETIME_DATETIME -void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(8, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif + return pos; } uint32_t ListEntitiesDateTimeResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void DateTimeStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->missing_state); - buffer.encode_fixed32(3, this->epoch_seconds); +uint8_t *DateTimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + ProtoEncode::encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->epoch_seconds); #ifdef USE_DEVICES - buffer.encode_uint32(4, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif + return pos; } uint32_t DateTimeStateResponse::calculate_size() const { uint32_t size = 0; @@ -3503,50 +3664,54 @@ bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_UPDATE -void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesUpdateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(5, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - buffer.encode_bool(6, this->disabled_by_default); - buffer.encode_uint32(7, static_cast(this->entity_category)); - buffer.encode_string(8, this->device_class); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); #ifdef USE_DEVICES - buffer.encode_uint32(9, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif + return pos; } uint32_t ListEntitiesUpdateResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); - size += ProtoSize::calc_length(1, this->device_class.size()); + size += this->entity_category ? 2 : 0; + size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif return size; } -void UpdateStateResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.write_tag_and_fixed32(13, this->key); - buffer.encode_bool(2, this->missing_state); - buffer.encode_bool(3, this->in_progress); - buffer.encode_bool(4, this->has_progress); - buffer.encode_float(5, this->progress); - buffer.encode_string(6, this->current_version); - buffer.encode_string(7, this->latest_version); - buffer.encode_string(8, this->title); - buffer.encode_string(9, this->release_summary); - buffer.encode_string(10, this->release_url); +uint8_t *UpdateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->in_progress); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->has_progress); + ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->progress); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->current_version); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->latest_version); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->title); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->release_summary); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->release_url); #ifdef USE_DEVICES - buffer.encode_uint32(11, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); #endif + return pos; } uint32_t UpdateStateResponse::calculate_size() const { uint32_t size = 0; @@ -3604,7 +3769,11 @@ bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited valu } return true; } -void ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer) const { buffer.encode_bytes(1, this->data, this->data_len); } +uint8_t *ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); + return pos; +} uint32_t ZWaveProxyFrame::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->data_len); @@ -3632,43 +3801,47 @@ bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited va } return true; } -void ZWaveProxyRequest::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, static_cast(this->type)); - buffer.encode_bytes(2, this->data, this->data_len); +uint8_t *ZWaveProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); + return pos; } uint32_t ZWaveProxyRequest::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_uint32(1, static_cast(this->type)); + size += this->type ? 2 : 0; size += ProtoSize::calc_length(1, this->data_len); return size; } #endif #ifdef USE_INFRARED -void ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_string(1, this->object_id); - buffer.write_tag_and_fixed32(21, this->key); - buffer.encode_string(3, this->name); +uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - buffer.encode_string(4, this->icon); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); #endif - buffer.encode_bool(5, this->disabled_by_default); - buffer.encode_uint32(6, static_cast(this->entity_category)); + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); #ifdef USE_DEVICES - buffer.encode_uint32(7, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); #endif - buffer.encode_uint32(8, this->capabilities); - buffer.encode_uint32(9, this->receiver_frequency); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->receiver_frequency); + return pos; } uint32_t ListEntitiesInfraredResponse::calculate_size() const { uint32_t size = 0; - size += ProtoSize::calc_length(1, this->object_id.size()); + size += 2 + this->object_id.size(); size += 5; - size += ProtoSize::calc_length(1, this->name.size()); + size += 2 + this->name.size(); #ifdef USE_ENTITY_ICON - size += ProtoSize::calc_length(1, this->icon.size()); + size += !this->icon.empty() ? 2 + this->icon.size() : 0; #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += ProtoSize::calc_uint32(1, static_cast(this->entity_category)); + size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, this->device_id); #endif @@ -3719,14 +3892,16 @@ bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto3 } return true; } -void InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer) const { +uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES - buffer.encode_uint32(1, this->device_id); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); #endif - buffer.write_tag_and_fixed32(21, this->key); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); for (const auto &it : *this->timings) { - buffer.encode_sint32(3, it, true); + ProtoEncode::encode_sint32(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); } + return pos; } uint32_t InfraredRFReceiveEvent::calculate_size() const { uint32_t size = 0; @@ -3768,9 +3943,11 @@ bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_ } return true; } -void SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->instance); - buffer.encode_bytes(2, this->data_ptr_, this->data_len_); +uint8_t *SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); + ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); + return pos; } uint32_t SerialProxyDataReceived::calculate_size() const { uint32_t size = 0; @@ -3823,9 +4000,11 @@ bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_vari } return true; } -void SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->instance); - buffer.encode_uint32(2, this->line_states); +uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); + return pos; } uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { uint32_t size = 0; @@ -3846,17 +4025,19 @@ bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t v } return true; } -void SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint32(1, this->instance); - buffer.encode_uint32(2, static_cast(this->type)); - buffer.encode_uint32(3, static_cast(this->status)); - buffer.encode_string(4, this->error_message); +uint8_t *SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error_message); + return pos; } uint32_t SerialProxyRequestResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_uint32(1, static_cast(this->type)); - size += ProtoSize::calc_uint32(1, static_cast(this->status)); + size += this->type ? 2 : 0; + size += this->status ? 2 : 0; size += ProtoSize::calc_length(1, this->error_message.size()); return size; } @@ -3884,9 +4065,11 @@ bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto } return true; } -void BluetoothSetConnectionParamsResponse::encode(ProtoWriteBuffer &buffer) const { - buffer.encode_uint64(1, this->address); - buffer.encode_int32(2, this->error); +uint8_t *BluetoothSetConnectionParamsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->error); + return pos; } uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { uint32_t size = 0; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14f6c704ae..3b239db36c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -412,7 +412,7 @@ class HelloResponse final : public ProtoMessage { uint32_t api_version_minor{0}; StringRef server_info{}; StringRef name{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -477,7 +477,7 @@ class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -492,7 +492,7 @@ class DeviceInfo final : public ProtoMessage { uint32_t device_id{0}; StringRef name{}; uint32_t area_id{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -506,7 +506,7 @@ class SerialProxyInfo final : public ProtoMessage { public: StringRef name{}; enums::SerialProxyPortType port_type{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -574,7 +574,7 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -605,7 +605,7 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { #endif StringRef device_class{}; bool is_status_binary_sensor{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -622,7 +622,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { #endif bool state{false}; bool missing_state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -644,7 +644,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { bool supports_tilt{false}; StringRef device_class{}; bool supports_stop{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -662,7 +662,7 @@ class CoverStateResponse final : public StateResponseProtoMessage { float position{0.0f}; float tilt{0.0f}; enums::CoverOperation current_operation{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -704,7 +704,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { bool supports_direction{false}; int32_t supported_speed_count{0}; const std::vector *supported_preset_modes{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -724,7 +724,7 @@ class FanStateResponse final : public StateResponseProtoMessage { enums::FanDirection direction{}; int32_t speed_level{0}; StringRef preset_mode{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -771,7 +771,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; const FixedVector *effects{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -798,7 +798,7 @@ class LightStateResponse final : public StateResponseProtoMessage { float cold_white{0.0f}; float warm_white{0.0f}; StringRef effect{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -862,7 +862,7 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { bool force_update{false}; StringRef device_class{}; enums::SensorStateClass state_class{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -879,7 +879,7 @@ class SensorStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -898,7 +898,7 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { #endif bool assumed_state{false}; StringRef device_class{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -914,7 +914,7 @@ class SwitchStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("switch_state_response"); } #endif bool state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -948,7 +948,7 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } #endif StringRef device_class{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -965,7 +965,7 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #endif StringRef state{}; bool missing_state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1004,7 +1004,7 @@ class SubscribeLogsResponse final : public ProtoMessage { this->message_ptr_ = data; this->message_len_ = len; } - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1037,7 +1037,7 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); } #endif bool success{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1051,7 +1051,7 @@ class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key{}; StringRef value{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1080,7 +1080,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON StringRef response_template{}; #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1124,7 +1124,7 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { StringRef entity_id{}; StringRef attribute{}; bool once{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1215,7 +1215,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name{}; enums::ServiceArgType type{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1234,7 +1234,7 @@ class ListEntitiesServicesResponse final : public ProtoMessage { uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1304,7 +1304,7 @@ class ExecuteServiceResponse final : public ProtoMessage { const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1321,7 +1321,7 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); } #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1343,7 +1343,7 @@ class CameraImageResponse final : public StateResponseProtoMessage { this->data_len_ = len; } bool done{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1394,7 +1394,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1422,7 +1422,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { StringRef custom_preset{}; float current_humidity{0.0f}; float target_humidity{0.0f}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1480,7 +1480,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { float target_temperature_step{0.0f}; const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1501,7 +1501,7 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1545,7 +1545,7 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { StringRef unit_of_measurement{}; enums::NumberMode mode{}; StringRef device_class{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1562,7 +1562,7 @@ class NumberStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1596,7 +1596,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } #endif const FixedVector *options{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1613,7 +1613,7 @@ class SelectStateResponse final : public StateResponseProtoMessage { #endif StringRef state{}; bool missing_state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1650,7 +1650,7 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { const FixedVector *tones{}; bool supports_duration{false}; bool supports_volume{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1666,7 +1666,7 @@ class SirenStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("siren_state_response"); } #endif bool state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1711,7 +1711,7 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { bool supports_open{false}; bool requires_code{false}; StringRef code_format{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1727,7 +1727,7 @@ class LockStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("lock_state_response"); } #endif enums::LockState state{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1764,7 +1764,7 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } #endif StringRef device_class{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1796,7 +1796,7 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { uint32_t num_channels{0}; enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1814,7 +1814,7 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { bool supports_pause{false}; std::vector supported_formats{}; uint32_t feature_flags{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1832,7 +1832,7 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage { enums::MediaPlayerState state{}; float volume{0.0f}; bool muted{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1888,7 +1888,7 @@ class BluetoothLERawAdvertisement final : public ProtoMessage { uint32_t address_type{0}; uint8_t data[62]{}; uint8_t data_len{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1905,7 +1905,7 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { #endif std::array advertisements{}; uint16_t advertisements_len{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1942,7 +1942,7 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage { bool connected{false}; uint32_t mtu{0}; int32_t error{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1970,7 +1970,7 @@ class BluetoothGATTDescriptor final : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; uint32_t short_uuid{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1985,7 +1985,7 @@ class BluetoothGATTCharacteristic final : public ProtoMessage { uint32_t properties{0}; FixedVector descriptors{}; uint32_t short_uuid{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -1999,7 +1999,7 @@ class BluetoothGATTService final : public ProtoMessage { uint32_t handle{0}; FixedVector characteristics{}; uint32_t short_uuid{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2016,7 +2016,7 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage { #endif uint64_t address{0}; std::vector services{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2032,7 +2032,7 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); } #endif uint64_t address{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2071,7 +2071,7 @@ class BluetoothGATTReadResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2166,7 +2166,7 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2184,7 +2184,7 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage { uint32_t free{0}; uint32_t limit{0}; std::array allocated{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2202,7 +2202,7 @@ class BluetoothGATTErrorResponse final : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; int32_t error{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2219,7 +2219,7 @@ class BluetoothGATTWriteResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2236,7 +2236,7 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2254,7 +2254,7 @@ class BluetoothDevicePairingResponse final : public ProtoMessage { uint64_t address{0}; bool paired{false}; int32_t error{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2272,7 +2272,7 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2290,7 +2290,7 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2308,7 +2308,7 @@ class BluetoothScannerStateResponse final : public ProtoMessage { enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; enums::BluetoothScannerMode configured_mode{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2354,7 +2354,7 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { uint32_t noise_suppression_level{0}; uint32_t auto_gain{0}; float volume_multiplier{0.0f}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2374,7 +2374,7 @@ class VoiceAssistantRequest final : public ProtoMessage { uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; StringRef wake_word_phrase{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2436,7 +2436,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { const uint8_t *data{nullptr}; uint16_t data_len{0}; bool end{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2494,7 +2494,7 @@ class VoiceAssistantAnnounceFinished final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); } #endif bool success{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2507,7 +2507,7 @@ class VoiceAssistantWakeWord final : public ProtoMessage { StringRef id{}; StringRef wake_word{}; std::vector trained_languages{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2557,7 +2557,7 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage { std::vector available_wake_words{}; const std::vector *active_wake_words{}; uint32_t max_active_wake_words{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2592,7 +2592,7 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2608,7 +2608,7 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); } #endif enums::AlarmControlPanelState state{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2647,7 +2647,7 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { uint32_t max_length{0}; StringRef pattern{}; enums::TextMode mode{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2664,7 +2664,7 @@ class TextStateResponse final : public StateResponseProtoMessage { #endif StringRef state{}; bool missing_state{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2698,7 +2698,7 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); } #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2717,7 +2717,7 @@ class DateStateResponse final : public StateResponseProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2752,7 +2752,7 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); } #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2771,7 +2771,7 @@ class TimeStateResponse final : public StateResponseProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2808,7 +2808,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { #endif StringRef device_class{}; const FixedVector *event_types{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2824,7 +2824,7 @@ class EventResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("event_response"); } #endif StringRef event_type{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2845,7 +2845,7 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2862,7 +2862,7 @@ class ValveStateResponse final : public StateResponseProtoMessage { #endif float position{0.0f}; enums::ValveOperation current_operation{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2897,7 +2897,7 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); } #endif - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2914,7 +2914,7 @@ class DateTimeStateResponse final : public StateResponseProtoMessage { #endif bool missing_state{false}; uint32_t epoch_seconds{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2948,7 +2948,7 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } #endif StringRef device_class{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -2972,7 +2972,7 @@ class UpdateStateResponse final : public StateResponseProtoMessage { StringRef title{}; StringRef release_summary{}; StringRef release_url{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3007,7 +3007,7 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { #endif const uint8_t *data{nullptr}; uint16_t data_len{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3026,7 +3026,7 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { enums::ZWaveProxyRequestType type{}; const uint8_t *data{nullptr}; uint16_t data_len{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3047,7 +3047,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { #endif uint32_t capabilities{0}; uint32_t receiver_frequency{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3094,7 +3094,7 @@ class InfraredRFReceiveEvent final : public ProtoMessage { #endif uint32_t key{0}; const std::vector *timings{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3138,7 +3138,7 @@ class SerialProxyDataReceived final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3204,7 +3204,7 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage { #endif uint32_t instance{0}; uint32_t line_states{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3239,7 +3239,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { enums::SerialProxyRequestType type{}; enums::SerialProxyStatus status{}; StringRef error_message{}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; @@ -3277,7 +3277,7 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #endif uint64_t address{0}; int32_t error{0}; - void encode(ProtoWriteBuffer &buffer) const; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index d9fe0fe461..236e4a474a 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -145,14 +145,15 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size // [tag][v1][v2][body ..... body] // ^-- pos_ = element end, within buffer void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value, - void (*encode_fn)(const void *, ProtoWriteBuffer &)) { + uint8_t *(*encode_fn)(const void *, + ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { this->encode_field_raw(field_id, 2); // Reserve 1 byte for length varint (optimistic: submessage < 128 bytes) uint8_t *len_pos = this->pos_; this->debug_check_bounds_(1); this->pos_++; uint8_t *body_start = this->pos_; - encode_fn(value, *this); + this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); uint32_t body_size = static_cast(this->pos_ - body_start); if (body_size < 128) [[likely]] { // Common case: 1-byte varint, just backpatch @@ -173,22 +174,27 @@ void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value, // Non-template core for encode_optional_sub_message. void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, - void (*encode_fn)(const void *, ProtoWriteBuffer &)) { + uint8_t *(*encode_fn)(const void *, + ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { if (nested_size == 0) return; this->encode_field_raw(field_id, 2); this->encode_varint_raw(nested_size); #ifdef ESPHOME_DEBUG_API uint8_t *start = this->pos_; - encode_fn(value, *this); + this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); if (static_cast(this->pos_ - start) != nested_size) this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start); #else - encode_fn(value, *this); + this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); #endif } #ifdef ESPHOME_DEBUG_API +void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *end, const char *caller) { + ESP_LOGE(TAG, "Proto encode bounds check failed in %s: need %zu bytes, %td available", caller, bytes, end - pos); + abort(); +} void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) { if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) { ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes, @@ -201,6 +207,7 @@ void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expe expected, actual); abort(); } + #endif void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index b629018a91..e0a4e03189 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -25,6 +25,19 @@ constexpr uint8_t WIRE_TYPE_LENGTH_DELIMITED = 2; // string, bytes, embedded me constexpr uint8_t WIRE_TYPE_FIXED32 = 5; // fixed32, sfixed32, float constexpr uint8_t WIRE_TYPE_MASK = 0b111; // Mask to extract wire type from tag +// Reinterpret float bits as uint32_t without floating-point comparison. +// Used by both encode_float() and calc_float() to ensure identical zero checks. +// Uses union type-punning which is a GCC/Clang extension (not standard C++), +// but bit_cast/memcpy don't optimize to a no-op on xtensa-gcc (ESP8266). +inline uint32_t float_to_raw(float value) { + union { + float f; + uint32_t u; + } v; + v.f = value; + return v.u; +} + // Helper functions for ZigZag encoding/decoding inline constexpr uint32_t encode_zigzag32(int32_t value) { return (static_cast(value) << 1) ^ (static_cast(value >> 31)); @@ -195,6 +208,26 @@ class Proto32Bit { // NOTE: Proto64Bit class removed - wire type 1 (64-bit fixed) not supported +// Debug bounds checking for proto encode functions. +// In debug mode (ESPHOME_DEBUG_API), an extra end-of-buffer pointer is threaded +// through the entire encode chain. In production, these expand to nothing. +#ifdef ESPHOME_DEBUG_API +#define PROTO_ENCODE_DEBUG_PARAM , uint8_t *proto_debug_end_ +#define PROTO_ENCODE_DEBUG_ARG , proto_debug_end_ +#define PROTO_ENCODE_DEBUG_INIT(buf) , (buf)->data() + (buf)->size() +#define PROTO_ENCODE_CHECK_BOUNDS(pos, n) \ + do { \ + if ((pos) + (n) > proto_debug_end_) \ + proto_check_bounds_failed(pos, n, proto_debug_end_, __builtin_FUNCTION()); \ + } while (0) +void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *end, const char *caller); +#else +#define PROTO_ENCODE_DEBUG_PARAM +#define PROTO_ENCODE_DEBUG_ARG +#define PROTO_ENCODE_DEBUG_INIT(buf) +#define PROTO_ENCODE_CHECK_BOUNDS(pos, n) +#endif + class ProtoWriteBuffer { public: ProtoWriteBuffer(APIBuffer *buffer) : buffer_(buffer), pos_(buffer->data() + buffer->size()) {} @@ -207,15 +240,6 @@ class ProtoWriteBuffer { } this->encode_varint_raw_slow_(value); } - void encode_varint_raw_64(uint64_t value) { - while (value > 0x7F) { - this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value | 0x80); - value >>= 7; - } - this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value); - } /** * Encode a field key (tag/wire type combination). * @@ -229,123 +253,6 @@ class ProtoWriteBuffer { * Following https://protobuf.dev/programming-guides/encoding/#structure */ void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw((field_id << 3) | type); } - /// Write a single precomputed tag byte. Tag must be < 128. - inline void write_raw_byte(uint8_t b) ESPHOME_ALWAYS_INLINE { - this->debug_check_bounds_(1); - *this->pos_++ = b; - } - /// Write raw bytes to the buffer (no tag, no length prefix). - inline void encode_raw(const void *data, size_t len) ESPHOME_ALWAYS_INLINE { - this->debug_check_bounds_(len); - std::memcpy(this->pos_, data, len); - this->pos_ += len; - } - /// Write a precomputed tag byte + 32-bit value in one operation. - /// Tag must be a single-byte varint (< 128). No zero check. - inline void write_tag_and_fixed32(uint8_t tag, uint32_t value) ESPHOME_ALWAYS_INLINE { - this->debug_check_bounds_(5); - this->pos_[0] = tag; -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - std::memcpy(this->pos_ + 1, &value, 4); -#else - this->pos_[1] = static_cast(value & 0xFF); - this->pos_[2] = static_cast((value >> 8) & 0xFF); - this->pos_[3] = static_cast((value >> 16) & 0xFF); - this->pos_[4] = static_cast((value >> 24) & 0xFF); -#endif - this->pos_ += 5; - } - void encode_string(uint32_t field_id, const char *string, size_t len, bool force = false) { - if (len == 0 && !force) - return; - - this->encode_field_raw(field_id, 2); // type 2: Length-delimited string - this->encode_varint_raw(len); - // Direct memcpy into pre-sized buffer — avoids push_back() per-byte capacity checks - // and vector::insert() iterator overhead. ~10-11x faster for 16-32 byte strings. - this->debug_check_bounds_(len); - std::memcpy(this->pos_, string, len); - this->pos_ += len; - } - void encode_string(uint32_t field_id, const std::string &value, bool force = false) { - this->encode_string(field_id, value.data(), value.size(), force); - } - void encode_string(uint32_t field_id, const StringRef &ref, bool force = false) { - this->encode_string(field_id, ref.c_str(), ref.size(), force); - } - void encode_bytes(uint32_t field_id, const uint8_t *data, size_t len, bool force = false) { - this->encode_string(field_id, reinterpret_cast(data), len, force); - } - void encode_uint32(uint32_t field_id, uint32_t value, bool force = false) { - if (value == 0 && !force) - return; - this->encode_field_raw(field_id, 0); // type 0: Varint - uint32 - this->encode_varint_raw(value); - } - void encode_uint64(uint32_t field_id, uint64_t value, bool force = false) { - if (value == 0 && !force) - return; - this->encode_field_raw(field_id, 0); // type 0: Varint - uint64 - this->encode_varint_raw_64(value); - } - void encode_bool(uint32_t field_id, bool value, bool force = false) { - if (!value && !force) - return; - this->encode_field_raw(field_id, 0); // type 0: Varint - bool - this->debug_check_bounds_(1); - *this->pos_++ = value ? 0x01 : 0x00; - } - void encode_fixed32(uint32_t field_id, uint32_t value, bool force = false) { - if (value == 0 && !force) - return; - - this->encode_field_raw(field_id, 5); // type 5: 32-bit fixed32 - this->debug_check_bounds_(4); -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - // Protobuf fixed32 is little-endian, so direct copy works - std::memcpy(this->pos_, &value, 4); - this->pos_ += 4; -#else - *this->pos_++ = (value >> 0) & 0xFF; - *this->pos_++ = (value >> 8) & 0xFF; - *this->pos_++ = (value >> 16) & 0xFF; - *this->pos_++ = (value >> 24) & 0xFF; -#endif - } - // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally - // not supported to reduce overhead on embedded systems. All ESPHome devices are - // 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support - // is needed in the future, the necessary encoding/decoding functions must be added. - void encode_float(uint32_t field_id, float value, bool force = false) { - if (value == 0.0f && !force) - return; - - union { - float value; - uint32_t raw; - } val{}; - val.value = value; - this->encode_fixed32(field_id, val.raw); - } - void encode_int32(uint32_t field_id, int32_t value, bool force = false) { - if (value < 0) { - // negative int32 is always 10 byte long - this->encode_int64(field_id, value, force); - return; - } - this->encode_uint32(field_id, static_cast(value), force); - } - void encode_int64(uint32_t field_id, int64_t value, bool force = false) { - this->encode_uint64(field_id, static_cast(value), force); - } - void encode_sint32(uint32_t field_id, int32_t value, bool force = false) { - this->encode_uint32(field_id, encode_zigzag32(value), force); - } - void encode_sint64(uint32_t field_id, int64_t value, bool force = false) { - this->encode_uint64(field_id, encode_zigzag64(value), force); - } - /// Encode a packed repeated sint32 field (zero-copy from vector) - void encode_packed_sint32(uint32_t field_id, const std::vector &values); /// Single-pass encode for repeated submessage elements. /// Thin template wrapper; all buffer work is in the non-template core. template void encode_sub_message(uint32_t field_id, const T &value); @@ -353,12 +260,17 @@ class ProtoWriteBuffer { /// Thin template wrapper; all buffer work is in the non-template core. template void encode_optional_sub_message(uint32_t field_id, const T &value); + // NOLINTBEGIN(readability-identifier-naming) // Non-template core for encode_sub_message — backpatch approach. - void encode_sub_message(uint32_t field_id, const void *value, void (*encode_fn)(const void *, ProtoWriteBuffer &)); + void encode_sub_message(uint32_t field_id, const void *value, + uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)); // Non-template core for encode_optional_sub_message. void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, - void (*encode_fn)(const void *, ProtoWriteBuffer &)); + uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)); + // NOLINTEND(readability-identifier-naming) APIBuffer *get_buffer() const { return buffer_; } + uint8_t *get_pos() const { return pos_; } + void set_pos(uint8_t *pos) { pos_ = pos; } protected: // Slow path for encode_varint_raw values >= 128, outlined to keep fast path small @@ -375,6 +287,220 @@ class ProtoWriteBuffer { uint8_t *pos_; }; +// Varint encoding thresholds — used by both proto_encode_* free functions and ProtoSize. +constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128 +constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384 + +/// Static encode helpers for generated encode() functions. +/// Generated code hoists buffer.pos_ into a local uint8_t *__restrict__ pos, +/// then calls these methods which take pos by reference. No struct, no overhead. +/// For sub-messages, pos is synced back to buffer before the call and reloaded after. +class ProtoEncode { + public: + /// Write a multi-byte varint directly through a pos pointer. + template + static inline void encode_varint_raw_loop(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, T value) { + do { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = static_cast(value | 0x80); + value >>= 7; + } while (value > 0x7F); + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = static_cast(value); + } + static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t value) { + if (value < VARINT_MAX_1_BYTE) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = static_cast(value); + return; + } + encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + } + /// Encode a varint that is expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths). + static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t value) { + if (value < VARINT_MAX_1_BYTE) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = static_cast(value); + return; + } + if (value < VARINT_MAX_2_BYTE) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 2); + *pos++ = static_cast(value | 0x80); + *pos++ = static_cast(value >> 7); + return; + } + encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + } + static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint64_t value) { + if (value < VARINT_MAX_1_BYTE) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = static_cast(value); + return; + } + encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + } + static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t type) { + encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); + } + /// Write a single precomputed tag byte. Tag must be < 128. + static inline void ESPHOME_ALWAYS_INLINE write_raw_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint8_t b) { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = b; + } + /// Write raw bytes to the buffer (no tag, no length prefix). + static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + const void *data, size_t len) { + PROTO_ENCODE_CHECK_BOUNDS(pos, len); + std::memcpy(pos, data, len); + pos += len; + } + /// Encode tag + 1-byte length + raw string data. For strings with max_data_length < 128. + /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). + static inline void encode_short_string_force(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, + const StringRef &ref) { +#ifdef ESPHOME_DEBUG_API + assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); +#endif + PROTO_ENCODE_CHECK_BOUNDS(pos, 2 + ref.size()); + pos[0] = tag; + pos[1] = static_cast(ref.size()); + std::memcpy(pos + 2, ref.c_str(), ref.size()); + pos += 2 + ref.size(); + } + /// Write a precomputed tag byte + 32-bit value in one operation. + static inline void ESPHOME_ALWAYS_INLINE write_tag_and_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint8_t tag, uint32_t value) { + PROTO_ENCODE_CHECK_BOUNDS(pos, 5); + pos[0] = tag; +#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + std::memcpy(pos + 1, &value, 4); +#else + pos[1] = static_cast(value & 0xFF); + pos[2] = static_cast((value >> 8) & 0xFF); + pos[3] = static_cast((value >> 16) & 0xFF); + pos[4] = static_cast((value >> 24) & 0xFF); +#endif + pos += 5; + } + static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + const char *string, size_t len, bool force = false) { + if (len == 0 && !force) + return; + encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string + if (len < VARINT_MAX_1_BYTE) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len); + *pos++ = static_cast(len); + } else { + encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len); + PROTO_ENCODE_CHECK_BOUNDS(pos, len); + } + std::memcpy(pos, string, len); + pos += len; + } + static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + const std::string &value, bool force = false) { + encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size(), force); + } + static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + const StringRef &ref, bool force = false) { + encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size(), force); + } + static inline void encode_bytes(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + const uint8_t *data, size_t len, bool force = false) { + encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len, force); + } + static inline void encode_uint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + uint32_t value, bool force = false) { + if (value == 0 && !force) + return; + encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value); + } + static inline void encode_uint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + uint64_t value, bool force = false) { + if (value == 0 && !force) + return; + encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + } + static inline void encode_bool(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, bool value, + bool force = false) { + if (!value && !force) + return; + encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + *pos++ = value ? 0x01 : 0x00; + } + static inline void encode_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + uint32_t value, bool force = false) { + if (value == 0 && !force) + return; + encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5); + PROTO_ENCODE_CHECK_BOUNDS(pos, 4); +#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + std::memcpy(pos, &value, 4); + pos += 4; +#else + *pos++ = (value >> 0) & 0xFF; + *pos++ = (value >> 8) & 0xFF; + *pos++ = (value >> 16) & 0xFF; + *pos++ = (value >> 24) & 0xFF; +#endif + } + // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally + // not supported to reduce overhead on embedded systems. All ESPHome devices are + // 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support + // is needed in the future, the necessary encoding/decoding functions must be added. + static inline void encode_float(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, float value, + bool force = false) { + uint32_t raw = float_to_raw(value); + if (raw == 0 && !force) + return; + encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, raw); + } + static inline void encode_int32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int32_t value, + bool force = false) { + if (value < 0) { + // negative int32 is always 10 byte long + encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + return; + } + encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + } + static inline void encode_int64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int64_t value, + bool force = false) { + encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + } + static inline void encode_sint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + int32_t value, bool force = false) { + encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value), force); + } + static inline void encode_sint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, + int64_t value, bool force = false) { + encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value), force); + } + /// Sub-message encoding: sync pos to buffer, delegate, get pos from return value. + template + static inline void encode_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, ProtoWriteBuffer &buffer, + uint32_t field_id, const T &value) { + buffer.set_pos(pos); + buffer.encode_sub_message(field_id, value); + pos = buffer.get_pos(); + } + template + static inline void encode_optional_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) { + buffer.set_pos(pos); + buffer.encode_optional_sub_message(field_id, value); + pos = buffer.get_pos(); + } +}; + #ifdef HAS_PROTO_MESSAGE_DUMP /** * Fixed-size buffer for message dumps - avoids heap allocation. @@ -470,7 +596,7 @@ class ProtoMessage { // All call sites use templates to preserve the concrete type, so virtual // dispatch is not needed. This eliminates per-message vtable entries for // encode/calculate_size, saving ~1.3 KB of flash across all message types. - void encode(ProtoWriteBuffer &buffer) const {} + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); } uint32_t calculate_size() const { return 0; } #ifdef HAS_PROTO_MESSAGE_DUMP virtual const char *dump_to(DumpBuffer &out) const = 0; @@ -512,12 +638,24 @@ class ProtoDecodableMessage : public ProtoMessage { class ProtoSize { public: - // Varint encoding thresholds: values below each threshold fit in N bytes - static constexpr uint32_t VARINT_THRESHOLD_1_BYTE = 1 << 7; // 128 - static constexpr uint32_t VARINT_THRESHOLD_2_BYTE = 1 << 14; // 16384 + // Varint encoding thresholds — use namespace-level constants for 1/2 byte, + // class-level for 3/4 byte (only used within ProtoSize). + static constexpr uint32_t VARINT_THRESHOLD_1_BYTE = VARINT_MAX_1_BYTE; + static constexpr uint32_t VARINT_THRESHOLD_2_BYTE = VARINT_MAX_2_BYTE; static constexpr uint32_t VARINT_THRESHOLD_3_BYTE = 1 << 21; // 2097152 static constexpr uint32_t VARINT_THRESHOLD_4_BYTE = 1 << 28; // 268435456 + // Varint encoded length for a 16-bit value (1, 2, or 3 bytes). + // Fully inline — no slow path call for values >= 128. + static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint16(uint16_t value) { + return value < VARINT_THRESHOLD_1_BYTE ? 1 : (value < VARINT_THRESHOLD_2_BYTE ? 2 : 3); + } + + // Varint encoded length for an 8-bit value (1 or 2 bytes). + static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint8(uint8_t value) { + return value < VARINT_THRESHOLD_1_BYTE ? 1 : 2; + } + /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * @@ -531,6 +669,17 @@ class ProtoSize { return varint_wide(value); return varint_slow(value); } + /// Size of a varint expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths). + /// Inlines both checks; falls back to slow path for 3+ bytes. + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE varint_short(uint32_t value) { + if (value < VARINT_THRESHOLD_1_BYTE) [[likely]] + return 1; + if (value < VARINT_THRESHOLD_2_BYTE) [[likely]] + return 2; + if (__builtin_is_constant_evaluated()) + return varint_wide(value); + return varint_slow(value); + } private: // Slow path for varint >= 128, outlined to keep fast path small @@ -635,8 +784,8 @@ class ProtoSize { } static constexpr uint32_t calc_bool(uint32_t field_id_size, bool value) { return value ? field_id_size + 1 : 0; } static constexpr uint32_t calc_bool_force(uint32_t field_id_size) { return field_id_size + 1; } - static constexpr uint32_t calc_float(uint32_t field_id_size, float value) { - return value != 0.0f ? field_id_size + 4 : 0; + static uint32_t calc_float(uint32_t field_id_size, float value) { + return float_to_raw(value) != 0 ? field_id_size + 4 : 0; } static constexpr uint32_t calc_fixed32(uint32_t field_id_size, uint32_t value) { return value ? field_id_size + 4 : 0; @@ -645,10 +794,10 @@ class ProtoSize { return value ? field_id_size + 4 : 0; } static constexpr uint32_t calc_sint32(uint32_t field_id_size, int32_t value) { - return value ? field_id_size + varint(encode_zigzag32(value)) : 0; + return value ? field_id_size + varint_short(encode_zigzag32(value)) : 0; } static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_sint32_force(uint32_t field_id_size, int32_t value) { - return field_id_size + varint(encode_zigzag32(value)); + return field_id_size + varint_short(encode_zigzag32(value)); } static constexpr uint32_t calc_int64(uint32_t field_id_size, int64_t value) { return value ? field_id_size + varint(value) : 0; @@ -691,28 +840,9 @@ class ProtoSize { // Implementation of methods that depend on ProtoSize being fully defined -// Implementation of encode_packed_sint32 - must be after ProtoSize is defined -inline void ProtoWriteBuffer::encode_packed_sint32(uint32_t field_id, const std::vector &values) { - if (values.empty()) - return; - - // Calculate packed size - size_t packed_size = 0; - for (int value : values) { - packed_size += ProtoSize::varint(encode_zigzag32(value)); - } - - // Write tag (LENGTH_DELIMITED) + length + all zigzag-encoded values - this->encode_field_raw(field_id, WIRE_TYPE_LENGTH_DELIMITED); - this->encode_varint_raw(packed_size); - for (int value : values) { - this->encode_varint_raw(encode_zigzag32(value)); - } -} - // Encode thunk — converts void* back to concrete type for direct encode() call -template void proto_encode_msg(const void *msg, ProtoWriteBuffer &buf) { - static_cast(msg)->encode(buf); +template uint8_t *proto_encode_msg(const void *msg, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { + return static_cast(msg)->encode(buf PROTO_ENCODE_DEBUG_ARG); } // Thin template wrapper; delegates to non-template core in proto.cpp. diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index d1b8a6ef0d..29eadda927 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -275,7 +275,7 @@ template class APIRespondAction : public Action { protected: APIServer *parent_; - TemplatableValue success_{true}; + TemplatableFn success_{[](Ts...) -> bool { return true; }}; TemplatableValue error_message_{""}; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON std::function json_builder_; diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index 79bc7af4a9..9b43155563 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_LIGHTNING_ENERGY, ICON_FLASH, ICON_SIGNAL_DISTANCE_VARIANT, + STATE_CLASS_MEASUREMENT, UNIT_KILOMETER, ) @@ -20,13 +21,14 @@ CONFIG_SCHEMA = cv.Schema( unit_of_measurement=UNIT_KILOMETER, icon=ICON_SIGNAL_DISTANCE_VARIANT, accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_LIGHTNING_ENERGY): sensor.sensor_schema( icon=ICON_FLASH, accuracy_decimals=1, ), } -).extend(cv.COMPONENT_SCHEMA) +) async def to_code(config): diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 0780814ea6..94b68db4b3 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -169,53 +169,43 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): # Radar configuration if frontend_reset := config.get(CONF_HW_FRONTEND_RESET): - template_ = await cg.templatable(frontend_reset, args, int) + template_ = await cg.templatable(frontend_reset, args, cg.int8) cg.add(var.set_hw_frontend_reset(template_)) if freq := config.get(CONF_FREQUENCY): - template_ = await cg.templatable(freq, args, float) - template_ = int(template_ / 1000000) + if not cg.is_template(freq): + freq = int(freq / 1000000) + template_ = await cg.templatable(freq, args, cg.int_) cg.add(var.set_frequency(template_)) - if sens_dist := config.get(CONF_SENSING_DISTANCE): - template_ = await cg.templatable(sens_dist, args, int) + if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: + template_ = await cg.templatable(sens_dist, args, cg.int_) cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(selfcheck, args, cg.int32) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(protect, args, cg.int32) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(trig_base, args, cg.int32) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, float) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds - template_ = int(template_) + template_ = await cg.templatable(trig_keep, args, cg.int32) cg.add(var.set_trigger_keep(template_)) - if stage_gain := config.get(CONF_STAGE_GAIN): - template_ = await cg.templatable(stage_gain, args, int) + if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: + template_ = await cg.templatable(stage_gain, args, cg.int_) cg.add(var.set_stage_gain(template_)) if power := config.get(CONF_POWER_CONSUMPTION): - template_ = await cg.templatable(power, args, float) - template_ = int(template_ * 1000000) + if not cg.is_template(power): + power = int(power * 1000000) + template_ = await cg.templatable(power, args, cg.int_) cg.add(var.set_power_consumption(template_)) return var diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 4522e94846..5941cb35b4 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_REACTIVE_POWER, @@ -103,6 +104,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), cv.Required(CONF_LINE_FREQUENCY): cv.enum(LINE_FREQS, upper=True), diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index a510095217..7e5d85c57a 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -20,6 +20,7 @@ from esphome.const import ( DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_REACTIVE_POWER, @@ -131,7 +132,6 @@ ATM90E32_PHASE_SCHEMA = cv.Schema( cv.Optional(CONF_PHASE_ANGLE): sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_HARMONIC_POWER): sensor.sensor_schema( @@ -166,6 +166,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CHIP_TEMPERATURE): sensor.sensor_schema( diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c9b32e610..3c3a4988b5 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -32,7 +32,7 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, float) + template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, cg.float_) cg.add(var.set_mic_gain(template_)) return var diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index a950c1967b..46c277ce51 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -52,7 +52,7 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config.get(CONF_VOLUME), args, float) + template_ = await cg.templatable(config.get(CONF_VOLUME), args, cg.float_) cg.add(var.set_volume(template_)) return var diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index a17407f08f..88ed902a11 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -61,6 +61,15 @@ void BedJetClimate::dump_config() { } void BedJetClimate::setup() { + // Set custom modes once during setup — stored on Climate base class, wired via get_traits() + this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); + this->set_supported_custom_presets({ + this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", + "M1", + "M2", + "M3", + }); + // restore set points auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index 05f4a849e0..d12c2a8255 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -42,21 +42,14 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli climate::CLIMATE_MODE_DRY, }); - // It would be better if we had a slider for the fan modes. - traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); traits.set_supported_presets({ // If we support NONE, then have to decide what happens if the user switches to it (turn off?) // climate::CLIMATE_PRESET_NONE, // Climate doesn't have a "TURBO" mode, but we can use the BOOST preset instead. climate::CLIMATE_PRESET_BOOST, }); - // String literals are stored in rodata and valid for program lifetime - traits.set_supported_custom_presets({ - this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", - "M1", - "M2", - "M3", - }); + // Custom fan modes and presets are set once in setup(), stored on Climate base class, + // and wired automatically via get_traits() traits.set_visual_min_temperature(19.0); traits.set_visual_max_temperature(43.0); traits.set_visual_temperature_step(1.0); 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 660f75ccd9..0b36c299f6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -390,7 +390,7 @@ def validate_multi_click_timing(value): new_state = v_.get(CONF_STATE, not state) if new_state == state: raise cv.Invalid( - f"Timings must have alternating state. Indices {i} and {i + 1} have the same state {state}" + f"Timings must have alternating state. Indices {i - 1} and {i} have the same state {state}" ) if max_length is not None and max_length < min_length: raise cv.Invalid( @@ -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/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 37c6bf0092..2e45554f81 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -36,7 +36,7 @@ class TimeoutFilter : public Filter, public Component { template void set_timeout_value(T timeout) { this->timeout_delay_ = timeout; } protected: - TemplatableValue timeout_delay_{}; + TemplatableFn timeout_delay_{}; }; class DelayedOnOffFilter final : public Filter, public Component { @@ -49,8 +49,8 @@ class DelayedOnOffFilter final : public Filter, public Component { template void set_off_delay(T delay) { this->off_delay_ = delay; } protected: - TemplatableValue on_delay_{}; - TemplatableValue off_delay_{}; + TemplatableFn on_delay_{}; + TemplatableFn off_delay_{}; }; class DelayedOnFilter : public Filter, public Component { @@ -62,7 +62,7 @@ class DelayedOnFilter : public Filter, public Component { template void set_delay(T delay) { this->delay_ = delay; } protected: - TemplatableValue delay_{}; + TemplatableFn delay_{}; }; class DelayedOffFilter : public Filter, public Component { @@ -74,7 +74,7 @@ class DelayedOffFilter : public Filter, public Component { template void set_delay(T delay) { this->delay_ = delay; } protected: - TemplatableValue delay_{}; + TemplatableFn delay_{}; }; class InvertFilter : public Filter { @@ -155,7 +155,7 @@ class SettleFilter : public Filter, public Component { template void set_delay(T delay) { this->delay_ = delay; } protected: - TemplatableValue delay_{}; + TemplatableFn delay_{}; bool steady_{true}; }; diff --git a/esphome/components/bl0940/number/__init__.py b/esphome/components/bl0940/number/__init__.py index a640c2ae08..92ab2837b3 100644 --- a/esphome/components/bl0940/number/__init__.py +++ b/esphome/components/bl0940/number/__init__.py @@ -89,6 +89,6 @@ async def to_code(config): ) await cg.register_component(var, conf) - if restore_value := config.get(CONF_RESTORE_VALUE): + if restore_value := conf.get(CONF_RESTORE_VALUE): cg.add(var.set_restore_value(restore_value)) cg.add(getattr(bl0940, setter_method)(var)) diff --git a/esphome/components/bmp581_spi/sensor.py b/esphome/components/bmp581_spi/sensor.py index 75f60b2460..db0d0cd529 100644 --- a/esphome/components/bmp581_spi/sensor.py +++ b/esphome/components/bmp581_spi/sensor.py @@ -31,7 +31,7 @@ BMP581SPIComponent = bmp581_ns.class_( def check_spi_mode(config): spi_mode = config.get(CONF_SPI_MODE) if spi_mode not in VALID_SPI_MODES: - raise cv.Invalid("BMP581 only supports SPI mode 3") + raise cv.Invalid("BMP581 only supports SPI mode 0 or mode 3") return 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/button/button.cpp b/esphome/components/button/button.cpp index b1c491805e..2a2a645132 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -16,7 +16,7 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o } void Button::press() { - ESP_LOGD(TAG, "'%s' Pressed.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Pressed.", this->get_name().c_str()); this->press_action(); this->press_callback_.call(); } diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index c94c8647a9..7d3bf78f49 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -161,7 +161,7 @@ async def canbus_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) - if can_id := config.get(CONF_CAN_ID): + if (can_id := config.get(CONF_CAN_ID)) is not None: can_id = await cg.templatable(can_id, args, cg.uint32) cg.add(var.set_can_id(can_id)) cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID])) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 2709290862..0feb384ac2 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -423,11 +423,10 @@ def _register_setter_actions(): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_VALUE] - if cg.is_template(data): - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - else: - cg.add(getattr(var, _setter)(_map[data] if _map else data)) + if _map and not cg.is_template(data): + data = _map[data] + templ_ = await cg.templatable(data, args, _type) + cg.add(getattr(var, _setter)(templ_)) return var automation.register_action( diff --git a/esphome/components/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index 9b69576b43..af6866df78 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -9,9 +9,7 @@ MULTI_CONF = True cd74hc4067_ns = cg.esphome_ns.namespace("cd74hc4067") -CD74HC4067Component = cd74hc4067_ns.class_( - "CD74HC4067Component", cg.Component, cg.PollingComponent -) +CD74HC4067Component = cd74hc4067_ns.class_("CD74HC4067Component", cg.Component) CONF_PIN_S0 = "pin_s0" CONF_PIN_S1 = "pin_s1" diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 13dd7aa007..df77fa5c1c 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -488,16 +488,16 @@ async def climate_control_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(mode, args, ClimateMode) cg.add(var.set_mode(template_)) if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: - template_ = await cg.templatable(target_temp, args, float) + template_ = await cg.templatable(target_temp, args, cg.float_) cg.add(var.set_target_temperature(template_)) if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - template_ = await cg.templatable(target_temp_low, args, float) + template_ = await cg.templatable(target_temp_low, args, cg.float_) cg.add(var.set_target_temperature_low(template_)) if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - template_ = await cg.templatable(target_temp_high, args, float) + template_ = await cg.templatable(target_temp_high, args, cg.float_) cg.add(var.set_target_temperature_high(template_)) if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None: - template_ = await cg.templatable(target_humidity, args, float) + template_ = await cg.templatable(target_humidity, args, cg.float_) cg.add(var.set_target_humidity(template_)) if (fan_mode := config.get(CONF_FAN_MODE)) is not None: template_ = await cg.templatable(fan_mode, args, ClimateFanMode) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 32cac0961c..e132497140 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -484,6 +484,11 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); + // Wire custom mode pointers from Climate-owned storage + if (this->supported_custom_fan_modes_) + traits.set_supported_custom_fan_modes_(this->supported_custom_fan_modes_); + if (this->supported_custom_presets_) + traits.set_supported_custom_presets_(this->supported_custom_presets_); #ifdef USE_CLIMATE_VISUAL_OVERRIDES if (!std::isnan(this->visual_min_temperature_override_)) { traits.set_visual_min_temperature(this->visual_min_temperature_override_); @@ -681,9 +686,8 @@ bool Climate::set_fan_mode_(ClimateFanMode mode) { } bool Climate::set_custom_fan_mode_(const char *mode, size_t len) { - auto traits = this->get_traits(); - return set_custom_mode(this->custom_fan_mode_, this->fan_mode, - traits.find_custom_fan_mode_(mode, len), this->has_custom_fan_mode()); + return set_custom_mode(this->custom_fan_mode_, this->fan_mode, this->find_custom_fan_mode_(mode, len), + this->has_custom_fan_mode()); } void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } @@ -691,8 +695,7 @@ void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } bool Climate::set_preset_(ClimatePreset preset) { return set_primary_mode(this->preset, this->custom_preset_, preset); } bool Climate::set_custom_preset_(const char *preset, size_t len) { - auto traits = this->get_traits(); - return set_custom_mode(this->custom_preset_, this->preset, traits.find_custom_preset_(preset, len), + return set_custom_mode(this->custom_preset_, this->preset, this->find_custom_preset_(preset, len), this->has_custom_preset()); } @@ -703,6 +706,10 @@ const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { + if (this->supported_custom_fan_modes_) { + return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len); + } + // Fallback for deprecated path: external components may set modes on ClimateTraits directly return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); } @@ -711,6 +718,10 @@ const char *Climate::find_custom_preset_(const char *custom_preset) { } const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { + if (this->supported_custom_presets_) { + return vector_find(*this->supported_custom_presets_, custom_preset, len); + } + // Fallback for deprecated path: external components may set modes on ClimateTraits directly return this->get_traits().find_custom_preset_(custom_preset, len); } diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 0251365dd8..04f653a2b0 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" @@ -234,6 +235,28 @@ class Climate : public EntityBase { void set_visual_max_humidity_override(float visual_max_humidity_override); #endif + /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). + void set_supported_custom_fan_modes(std::initializer_list modes) { + this->ensure_custom_fan_modes_().assign(modes.begin(), modes.end()); + } + void set_supported_custom_fan_modes(const std::vector &modes) { + this->ensure_custom_fan_modes_() = modes; + } + template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->ensure_custom_fan_modes_().assign(modes, modes + N); + } + + /// Set the supported custom presets (stored on Climate, referenced by ClimateTraits). + void set_supported_custom_presets(std::initializer_list presets) { + this->ensure_custom_presets_().assign(presets.begin(), presets.end()); + } + void set_supported_custom_presets(const std::vector &presets) { + this->ensure_custom_presets_() = presets; + } + template void set_supported_custom_presets(const char *const (&presets)[N]) { + this->ensure_custom_presets_().assign(presets, presets + N); + } + /// Check if a custom fan mode is currently active. bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } @@ -336,13 +359,14 @@ class Climate : public EntityBase { * called from publish_state() */ void save_state_(const ClimateTraits &traits); - void save_state_() { this->save_state_(this->traits()); } + void save_state_() { this->save_state_(this->get_traits()); } void dump_traits_(const char *tag); LazyCallbackManager state_callback_{}; LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; + #ifdef USE_CLIMATE_VISUAL_OVERRIDES float visual_min_temperature_override_{NAN}; float visual_max_temperature_override_{NAN}; @@ -353,16 +377,33 @@ class Climate : public EntityBase { #endif private: + /// Lazy-allocate custom mode vectors (never freed — entity lives forever). + std::vector &ensure_custom_fan_modes_() { + if (!this->supported_custom_fan_modes_) { + this->supported_custom_fan_modes_ = new std::vector(); // NOLINT + } + return *this->supported_custom_fan_modes_; + } + std::vector &ensure_custom_presets_() { + if (!this->supported_custom_presets_) { + this->supported_custom_presets_ = new std::vector(); // NOLINT + } + return *this->supported_custom_presets_; + } + + std::vector *supported_custom_fan_modes_{nullptr}; + std::vector *supported_custom_presets_{nullptr}; + /** The active custom fan mode (private - enforces use of safe setters). * - * Points to an entry in traits.supported_custom_fan_modes_ or nullptr. + * Points to an entry in supported_custom_fan_modes_ or nullptr. * Use get_custom_fan_mode() to read, set_custom_fan_mode_() to modify. */ const char *custom_fan_mode_{nullptr}; /** The active custom preset (private - enforces use of safe setters). * - * Points to an entry in traits.supported_custom_presets_ or nullptr. + * Points to an entry in supported_custom_presets_ or nullptr. * Use get_custom_preset() to read, set_custom_preset_() to modify. */ const char *custom_preset_{nullptr}; diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 9bf2d9acd3..398e25f69e 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -2,6 +2,33 @@ namespace esphome::climate { +// Compat: shared empty vector for getters when no custom modes are set. +// Remove in 2026.11.0 when deprecated ClimateTraits setters are removed +// and getters can return const vector * instead of const vector &. +static const std::vector EMPTY_CUSTOM_MODES; // NOLINT + +const std::vector &ClimateTraits::get_supported_custom_fan_modes() const { + if (this->supported_custom_fan_modes_) { + return *this->supported_custom_fan_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0. + if (!this->compat_custom_fan_modes_.empty()) { + return this->compat_custom_fan_modes_; + } + return EMPTY_CUSTOM_MODES; +} + +const std::vector &ClimateTraits::get_supported_custom_presets() const { + if (this->supported_custom_presets_) { + return *this->supported_custom_presets_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0. + if (!this->compat_custom_presets_.empty()) { + return this->compat_custom_presets_; + } + return EMPTY_CUSTOM_MODES; +} + int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_target_temperature_step_); } diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 80ef0854d5..082b2127a9 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -147,27 +147,45 @@ class ClimateTraits { void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { - return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); + if (!this->supported_fan_modes_.empty()) { + return true; + } + // Same precedence as get_supported_custom_fan_modes() getter + if (this->supported_custom_fan_modes_) { + return !this->supported_custom_fan_modes_->empty(); + } + return !this->compat_custom_fan_modes_.empty(); // Compat: remove in 2026.11.0 } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { - this->supported_custom_fan_modes_ = modes; + // Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector). + this->compat_custom_fan_modes_ = modes; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(const std::vector &modes) { - this->supported_custom_fan_modes_ = modes; + this->compat_custom_fan_modes_ = modes; } - template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { - this->supported_custom_fan_modes_.assign(modes, modes + N); + // Remove before 2026.11.0 + template + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->compat_custom_fan_modes_.assign(modes, modes + N); } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_custom_fan_modes(const std::vector &modes) = delete; void set_supported_custom_fan_modes(std::initializer_list modes) = delete; - const std::vector &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &get_supported_custom_fan_modes() const; bool supports_custom_fan_mode(const char *custom_fan_mode) const { - return vector_contains(this->supported_custom_fan_modes_, custom_fan_mode); + return (this->supported_custom_fan_modes_ && + vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode)) || + vector_contains(this->compat_custom_fan_modes_, custom_fan_mode); // Compat: remove in 2026.11.0 } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { return this->supports_custom_fan_mode(custom_fan_mode.c_str()); @@ -179,23 +197,32 @@ class ClimateTraits { bool get_supports_presets() const { return !this->supported_presets_.empty(); } const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { - this->supported_custom_presets_ = presets; + this->compat_custom_presets_ = presets; } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(const std::vector &presets) { - this->supported_custom_presets_ = presets; + this->compat_custom_presets_ = presets; } - template void set_supported_custom_presets(const char *const (&presets)[N]) { - this->supported_custom_presets_.assign(presets, presets + N); + // Remove before 2026.11.0 + template + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_custom_presets(const char *const (&presets)[N]) { + this->compat_custom_presets_.assign(presets, presets + N); } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_custom_presets(const std::vector &presets) = delete; void set_supported_custom_presets(std::initializer_list presets) = delete; - const std::vector &get_supported_custom_presets() const { return this->supported_custom_presets_; } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &get_supported_custom_presets() const; bool supports_custom_preset(const char *custom_preset) const { - return vector_contains(this->supported_custom_presets_, custom_preset); + return (this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset)) || + vector_contains(this->compat_custom_presets_, custom_preset); // Compat: remove in 2026.11.0 } bool supports_custom_preset(const std::string &custom_preset) const { return this->supports_custom_preset(custom_preset.c_str()); @@ -258,13 +285,25 @@ class ClimateTraits { } } + /// Set custom mode pointers (only Climate::get_traits() should call these). + void set_supported_custom_fan_modes_(const std::vector *modes) { + this->supported_custom_fan_modes_ = modes; + } + void set_supported_custom_presets_(const std::vector *presets) { + this->supported_custom_presets_ = presets; + } + /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead const char *find_custom_fan_mode_(const char *custom_fan_mode) const { return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); } const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); + if (this->supported_custom_fan_modes_) { + return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len); + } + // Compat: check owned vector from deprecated setters. Remove in 2026.11.0. + return vector_find(this->compat_custom_fan_modes_, custom_fan_mode, len); } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found @@ -273,7 +312,11 @@ class ClimateTraits { return this->find_custom_preset_(custom_preset, strlen(custom_preset)); } const char *find_custom_preset_(const char *custom_preset, size_t len) const { - return vector_find(this->supported_custom_presets_, custom_preset, len); + if (this->supported_custom_presets_) { + return vector_find(*this->supported_custom_presets_, custom_preset, len); + } + // Compat: check owned vector from deprecated setters. Remove in 2026.11.0. + return vector_find(this->compat_custom_presets_, custom_preset, len); } uint32_t feature_flags_{0}; @@ -289,16 +332,17 @@ class ClimateTraits { climate::ClimateSwingModeMask supported_swing_modes_; climate::ClimatePresetMask supported_presets_; - /** Custom mode storage using const char* pointers to eliminate std::string overhead. + /** Custom mode storage - pointers to vectors owned by the Climate base class. * - * Pointers must remain valid for the ClimateTraits lifetime. Safe patterns: - * - String literals: set_supported_custom_fan_modes({"Turbo", "Silent"}) - * - Static const data: static const char* MODE = "Eco"; - * - * Climate class setters validate pointers are from these vectors before storing. + * ClimateTraits does not own this data; Climate stores the vectors and + * get_traits() wires these pointers automatically. */ - std::vector supported_custom_fan_modes_; - std::vector supported_custom_presets_; + const std::vector *supported_custom_fan_modes_{nullptr}; + const std::vector *supported_custom_presets_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector). + // Remove in 2026.11.0. + std::vector compat_custom_fan_modes_; + std::vector compat_custom_presets_; }; } // namespace esphome::climate diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 0eb37e3029..846d3fd883 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -32,4 +32,6 @@ ICON_CURRENT_DC = "mdi:current-dc" ICON_SOLAR_PANEL = "mdi:solar-panel" ICON_SOLAR_POWER = "mdi:solar-power" +KEY_METADATA = "metadata" + UNIT_AMPERE_HOUR = "Ah" diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 14c600d71f..bdaa35c467 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -7,6 +7,12 @@ namespace copy { static const char *const TAG = "copy.fan"; void CopyFan::setup() { + // Copy preset modes once from source fan — stored on Fan base class + auto source_traits = source_->get_traits(); + if (source_traits.supports_preset_modes()) { + this->set_supported_preset_modes(source_traits.supported_preset_modes()); + } + source_->add_on_state_callback([this]() { this->copy_state_from_source_(); this->publish_state(); @@ -39,7 +45,8 @@ fan::FanTraits CopyFan::get_traits() { traits.set_speed(base.supports_speed()); traits.set_supported_speed_count(base.supported_speed_count()); traits.set_direction(base.supports_direction()); - traits.set_supported_preset_modes(base.supported_preset_modes()); + // Preset modes are set once in setup() and wired via wire_preset_modes_() + this->wire_preset_modes_(traits); return traits; } diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c330241f4d..fdfca55f0f 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -300,16 +300,16 @@ async def cover_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if (stop := config.get(CONF_STOP)) is not None: - template_ = await cg.templatable(stop, args, bool) + template_ = await cg.templatable(stop, args, cg.bool_) cg.add(var.set_stop(template_)) if (state := config.get(CONF_STATE)) is not None: - template_ = await cg.templatable(state, args, float) + template_ = await cg.templatable(state, args, cg.float_) cg.add(var.set_position(template_)) if (position := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position, args, float) + template_ = await cg.templatable(position, args, cg.float_) cg.add(var.set_position(template_)) if (tilt := config.get(CONF_TILT)) is not None: - template_ = await cg.templatable(tilt, args, float) + template_ = await cg.templatable(tilt, args, cg.float_) cg.add(var.set_tilt(template_)) return var diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index d2383bd01b..0c6ae0d821 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, UNIT_AMPERE, UNIT_VOLT, UNIT_WATT, @@ -82,16 +83,19 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_VOLT, accuracy_decimals=0, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CURRENT): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=1, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), } ) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 6572a914aa..94ed66d7cc 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -32,52 +32,56 @@ DEPENDENCIES = ["uart"] cse7766_ns = cg.esphome_ns.namespace("cse7766") CSE7766Component = cse7766_ns.class_("CSE7766Component", cg.Component, uart.UARTDevice) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(CSE7766Component), - cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_ENERGY): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=3, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT_AMPS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_APPARENT_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_REACTIVE_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, - accuracy_decimals=1, - device_class=DEVICE_CLASS_REACTIVE_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( - accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), - } -).extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CSE7766Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ENERGY): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_REACTIVE_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "cse7766", baud_rate=4800, parity="EVEN", require_rx=True ) diff --git a/esphome/components/cst226/binary_sensor/__init__.py b/esphome/components/cst226/binary_sensor/__init__.py index d95f0d2b4d..324d794772 100644 --- a/esphome/components/cst226/binary_sensor/__init__.py +++ b/esphome/components/cst226/binary_sensor/__init__.py @@ -15,10 +15,14 @@ CST226Button = cst226_ns.class_( cg.Parented.template(CST226Touchscreen), ) -CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST226Button).extend( - { - cv.GenerateID(CONF_CST226_ID): cv.use_id(CST226Touchscreen), - } +CONFIG_SCHEMA = ( + binary_sensor.binary_sensor_schema(CST226Button) + .extend( + { + cv.GenerateID(CONF_CST226_ID): cv.use_id(CST226Touchscreen), + } + ) + .extend(cv.COMPONENT_SCHEMA) ) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 90835624bf..895ac4e243 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -204,7 +204,8 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ("month", date_config[CONF_MONTH]), ("year", date_config[CONF_YEAR]), ) - cg.add(action_var.set_date(date_struct)) + template_ = await cg.templatable(date_struct, args, cg.ESPTime) + cg.add(action_var.set_date(template_)) return action_var @@ -236,7 +237,8 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ("minute", time_config[CONF_MINUTE]), ("hour", time_config[CONF_HOUR]), ) - cg.add(action_var.set_time(time_struct)) + template_ = await cg.templatable(time_struct, args, cg.ESPTime) + cg.add(action_var.set_time(template_)) return action_var @@ -271,5 +273,6 @@ async def datetime_datetime_set_to_code(config, action_id, template_arg, args): ("month", datetime_config[CONF_MONTH]), ("year", datetime_config[CONF_YEAR]), ) - cg.add(action_var.set_datetime(datetime_struct)) + template_ = await cg.templatable(datetime_struct, args, cg.ESPTime) + cg.add(action_var.set_datetime(template_)) return action_var diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 0a716d666e..a018ce5c3b 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -8,12 +8,14 @@ from esphome.const import ( CONF_FRAGMENTATION, CONF_FREE, CONF_LOOP_TIME, + DEVICE_CLASS_FREQUENCY, ENTITY_CATEGORY_DIAGNOSTIC, ICON_COUNTER, ICON_TIMER, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, + STATE_CLASS_MEASUREMENT, UNIT_BYTES, UNIT_HERTZ, UNIT_MILLISECOND, @@ -38,12 +40,14 @@ CONFIG_SCHEMA = { icon=ICON_COUNTER, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_BLOCK): sensor.sensor_schema( unit_of_measurement=UNIT_BYTES, icon=ICON_COUNTER, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_FRAGMENTATION): cv.All( cv.Any( @@ -59,6 +63,7 @@ CONFIG_SCHEMA = { icon=ICON_COUNTER, accuracy_decimals=1, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), ), cv.Optional(CONF_MIN_FREE): cv.All( @@ -72,6 +77,7 @@ CONFIG_SCHEMA = { icon=ICON_COUNTER, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), ), cv.Optional(CONF_LOOP_TIME): sensor.sensor_schema( @@ -79,6 +85,7 @@ CONFIG_SCHEMA = { icon=ICON_TIMER, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_PSRAM): cv.All( cv.only_on_esp32, @@ -88,6 +95,7 @@ CONFIG_SCHEMA = { icon=ICON_COUNTER, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), ), cv.Optional(CONF_CPU_FREQUENCY): cv.All( @@ -95,7 +103,9 @@ CONFIG_SCHEMA = { unit_of_measurement=UNIT_HERTZ, icon="mdi:speedometer", accuracy_decimals=0, + device_class=DEVICE_CLASS_FREQUENCY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), ), } diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index c5f07ac114..c6d328b1bc 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -16,6 +16,19 @@ class DemoClimate : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { + // Set custom modes once during setup — stored on Climate base class, wired via get_traits() + switch (type_) { + case DemoClimateType::TYPE_1: + break; + case DemoClimateType::TYPE_2: + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + this->set_supported_custom_presets({"My Preset"}); + break; + case DemoClimateType::TYPE_3: + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + break; + } + // Set initial state switch (type_) { case DemoClimateType::TYPE_1: this->current_temperature = 20.0; @@ -105,14 +118,13 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_FAN_DIFFUSE, climate::CLIMATE_FAN_QUIET, }); - traits.set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + // Custom fan modes and presets are set once in setup() traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, }); - traits.set_supported_custom_presets({"My Preset"}); break; case DemoClimateType::TYPE_3: traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | @@ -123,7 +135,7 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, }); - traits.set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + // Custom fan modes are set once in setup() traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index c49420f060..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( @@ -122,7 +126,7 @@ async def dfplayer_previous_to_code(config, action_id, template_arg, args): async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, float) + template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) cg.add(var.set_file(template_)) return var @@ -143,10 +147,10 @@ async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): async def dfplayer_play_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, float) + template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) cg.add(var.set_file(template_)) if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, float) + template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) cg.add(var.set_loop(template_)) return var @@ -167,13 +171,13 @@ async def dfplayer_play_to_code(config, action_id, template_arg, args): async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FOLDER], args, float) + template_ = await cg.templatable(config[CONF_FOLDER], args, cg.uint16) cg.add(var.set_folder(template_)) if CONF_FILE in config: - template_ = await cg.templatable(config[CONF_FILE], args, float) + template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) cg.add(var.set_file(template_)) if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, float) + template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) cg.add(var.set_loop(template_)) return var @@ -213,7 +217,7 @@ async def dfplayer_set_device_to_code(config, action_id, template_arg, args): async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VOLUME], args, float) + template_ = await cg.templatable(config[CONF_VOLUME], args, cg.uint8) cg.add(var.set_volume(template_)) return var diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index ba77e56abb..943c510279 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -97,7 +97,7 @@ def range_segment_list(input): ) largest_distance = -1 - for distance in input: + for i, distance in enumerate(input): if isinstance(distance, cv.Lambda): continue m = cv.distance(distance) @@ -112,7 +112,7 @@ def range_segment_list(input): ) largest_distance = m # Replace distance object with meters float - input[input.index(distance)] = m + input[i] = m return input @@ -159,31 +159,31 @@ async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args await cg.register_parented(var, config[CONF_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): - template_ = await cg.templatable(factory_reset_config, args, int) + template_ = await cg.templatable(factory_reset_config, args, cg.int8) cg.add(var.set_factory_reset(template_)) if CONF_DETECTION_SEGMENTS in config: segments = config[CONF_DETECTION_SEGMENTS] if len(segments) >= 2: - template_ = await cg.templatable(segments[0], args, float) + template_ = await cg.templatable(segments[0], args, cg.float_) cg.add(var.set_det_min1(template_)) - template_ = await cg.templatable(segments[1], args, float) + template_ = await cg.templatable(segments[1], args, cg.float_) cg.add(var.set_det_max1(template_)) if len(segments) >= 4: - template_ = await cg.templatable(segments[2], args, float) + template_ = await cg.templatable(segments[2], args, cg.float_) cg.add(var.set_det_min2(template_)) - template_ = await cg.templatable(segments[3], args, float) + template_ = await cg.templatable(segments[3], args, cg.float_) cg.add(var.set_det_max2(template_)) if len(segments) >= 6: - template_ = await cg.templatable(segments[4], args, float) + template_ = await cg.templatable(segments[4], args, cg.float_) cg.add(var.set_det_min3(template_)) - template_ = await cg.templatable(segments[5], args, float) + template_ = await cg.templatable(segments[5], args, cg.float_) cg.add(var.set_det_max3(template_)) if len(segments) >= 8: - template_ = await cg.templatable(segments[6], args, float) + template_ = await cg.templatable(segments[6], args, cg.float_) cg.add(var.set_det_min4(template_)) - template_ = await cg.templatable(segments[7], args, float) + template_ = await cg.templatable(segments[7], args, cg.float_) cg.add(var.set_det_max4(template_)) if CONF_OUTPUT_LATENCY in config: template_ = await cg.templatable( @@ -200,7 +200,7 @@ async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args template_ = template_.total_milliseconds / 1000 cg.add(var.set_delay_after_disappear(template_)) if CONF_SENSITIVITY in config: - template_ = await cg.templatable(config[CONF_SENSITIVITY], args, int) + template_ = await cg.templatable(config[CONF_SENSITIVITY], args, cg.int8) cg.add(var.set_sensitivity(template_)) return var diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 6367f88acc..744b5d16c4 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -1,6 +1,9 @@ +from dataclasses import dataclass + from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import KEY_METADATA import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, @@ -16,7 +19,9 @@ from esphome.const import ( SCHEDULER_DONT_RUN, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +DOMAIN = "display" IS_PLATFORM_COMPONENT = True display_ns = cg.esphome_ns.namespace("display") @@ -112,8 +117,9 @@ FULL_DISPLAY_SCHEMA.add_extra(_validate_test_card) async def setup_display_core_(var, config): - if CONF_ROTATION in config: - cg.add(var.set_rotation(DISPLAY_ROTATIONS[config[CONF_ROTATION]])) + if rotation := config.get(CONF_ROTATION, 0): + # Default initialised value for rotation is 0 + cg.add(var.set_rotation(DISPLAY_ROTATIONS[rotation])) if (auto_clear := config.get(CONF_AUTO_CLEAR_ENABLED)) is not None: # Default to true if pages or lambda is specified. Ideally this would be done during validation, but @@ -146,6 +152,39 @@ async def setup_display_core_(var, config): cg.add(var.show_test_card()) +# Storage of display metadata in a central location, accessible via the id + + +@dataclass(frozen=True) +class DisplayMetaData: + width: int = 0 + height: int = 0 + has_writer: bool = False + has_hardware_rotation: bool = False + + +def get_all_display_metadata() -> dict[str, DisplayMetaData]: + """Get all display metadata.""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + + +def get_display_metadata(display_id: str) -> DisplayMetaData | None: + """Get display metadata by ID for use by other components.""" + return get_all_display_metadata().get(display_id, DisplayMetaData()) + + +def add_metadata( + id: str | MockObj, + width: int, + height: int, + has_writer: bool, + has_hardware_rotation: bool = False, +): + get_all_display_metadata()[str(id)] = DisplayMetaData( + width, height, has_writer, has_hardware_rotation + ) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) @@ -168,7 +207,8 @@ async def display_page_show_to_code(config, action_id, template_arg, args): cg.add(var.set_page(template_)) else: paren = await cg.get_variable(config[CONF_ID]) - cg.add(var.set_page(paren)) + template_ = await cg.templatable(paren, args, DisplayPagePtr) + cg.add(var.set_page(template_)) return var diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index e40f6ec963..6e38300d0e 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -704,7 +704,7 @@ class Display : public PollingComponent { void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } /// Internal method to set the display rotation with. - void set_rotation(DisplayRotation rotation); + virtual void set_rotation(DisplayRotation rotation); // Internal method to set display auto clearing. void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index c9a0c7ee93..9125c43f0c 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -119,7 +119,7 @@ DisplayMenuOnPrevTrigger = display_menu_base_ns.class_( def validate_format(format): - if re.search(r"^%([+-])*(\d+)*(\.\d+)*[fg]$", format) is None: + if re.search(r"^%[+-]*(\d+)?(\.\d+)?[fg]$", format) is None: raise cv.Invalid( f"{CONF_FORMAT}: has to specify a printf-like format string specifying exactly one f or g type conversion, '{format}' provided" ) diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 7d76856f28..9c493bfcff 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -4,7 +4,7 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_UART_ID -CODEOWNERS = ["@glmnet", "@zuidwijk", "@PolarGoose"] +CODEOWNERS = ["@glmnet", "@PolarGoose"] MULTI_CONF = True @@ -16,6 +16,7 @@ CONF_DECRYPTION_KEY = "decryption_key" CONF_DSMR_ID = "dsmr_id" CONF_GAS_MBUS_ID = "gas_mbus_id" CONF_WATER_MBUS_ID = "water_mbus_id" +CONF_THERMAL_MBUS_ID = "thermal_mbus_id" CONF_MAX_TELEGRAM_LENGTH = "max_telegram_length" CONF_REQUEST_INTERVAL = "request_interval" CONF_REQUEST_PIN = "request_pin" @@ -35,7 +36,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CRC_CHECK, default=True): cv.boolean, cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, - cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_, + cv.Optional(CONF_THERMAL_MBUS_ID, default=3): 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" @@ -44,7 +46,9 @@ CONFIG_SCHEMA = cv.All( CONF_RECEIVE_TIMEOUT, default="200ms" ): cv.positive_time_period_milliseconds, } - ).extend(uart.UART_DEVICE_SCHEMA), + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), ) @@ -64,6 +68,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_GAS_MBUS_ID=" + str(config[CONF_GAS_MBUS_ID])) cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) + cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) # DSMR Parser cg.add_library("esphome/dsmr_parser", "1.1.0") diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 863af42d1b..c49614eaa9 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -122,42 +122,52 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("total_imported_energy"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_delivered_tariff1"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_delivered_tariff2"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_delivered_tariff3"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_delivered_tariff4"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("total_exported_energy"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_returned_tariff1"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_returned_tariff2"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_returned_tariff3"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("reactive_energy_returned_tariff4"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, ), cv.Optional("power_delivered"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT, diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index 301812e314..a1a8e0aec5 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -29,7 +29,7 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(): cv.declare_id(E131Component), cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True), } -) +).extend(cv.COMPONENT_SCHEMA) async def to_code(config): 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/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 3b6ad8a4ee..46c53c3b10 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -24,7 +24,6 @@ CODEOWNERS = ["@vincentscode", "@latonita"] ens160_ns = cg.esphome_ns.namespace("ens160_base") CONF_AQI = "aqi" -UNIT_INDEX = "index" CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 2657071f45..658f9e2c4a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -175,9 +175,7 @@ async def to_code(config): *model.get_constructor_args(config), ) - # Rotation is handled by setting the transform - display_config = {k: v for k, v in config.items() if k != CONF_ROTATION} - await display.register_display(var, display_config) + await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) @@ -201,16 +199,6 @@ async def to_code(config): transform[CONF_SWAP_XY] = False else: transform = {x: model.get_default(x, False) for x in TRANSFORM_OPTIONS} - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - elif rotation == 270: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] transform_str = "|".join( { str(getattr(Transform, x.upper())) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index ae1923a916..a2ca311b30 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -97,6 +97,23 @@ bool EPaperBase::reset() { return true; } +void EPaperBase::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (SWAP_XY | MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (MIRROR_Y | MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (SWAP_XY | MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + void EPaperBase::update() { if (this->state_ != EPaperState::IDLE) { ESP_LOGE(TAG, "Display already in state %s", epaper_state_to_string_()); @@ -280,11 +297,11 @@ bool EPaperBase::initialise(bool partial) { bool EPaperBase::rotate_coordinates_(int &x, int &y) { if (!this->get_clipping().inside(x, y)) return false; - if (this->transform_ & SWAP_XY) + if (this->effective_transform_ & SWAP_XY) std::swap(x, y); - if (this->transform_ & MIRROR_X) + if (this->effective_transform_ & MIRROR_X) x = this->width_ - x - 1; - if (this->transform_ & MIRROR_Y) + if (this->effective_transform_ & MIRROR_Y) y = this->height_ - y - 1; if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) return false; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index a743985518..2992ca5afd 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/display/display_buffer.h" +#include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" @@ -51,7 +51,14 @@ class EPaperBase : public Display, void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } - void set_transform(uint8_t transform) { this->transform_ = transform; } + void set_transform(uint8_t transform) { + this->transform_ = transform; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } void set_full_update_every(uint8_t full_update_every) { this->full_update_every_ = full_update_every; } void dump_config() override; @@ -103,12 +110,14 @@ class EPaperBase : public Display, this->fill(COLOR_ON); } + int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } + int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } + void draw_pixel_at(int x, int y, Color color) override; + protected: int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; - int get_width() override { return this->transform_ & SWAP_XY ? this->height_ : this->width_; } - int get_height() override { return this->transform_ & SWAP_XY ? this->width_ : this->height_; } - void draw_pixel_at(int x, int y, Color color) override; + bool is_using_partial_update_() const { return this->full_update_every_ > 1; } void process_state_(); const char *epaper_state_to_string_(); @@ -119,6 +128,7 @@ class EPaperBase : public Display, void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); + void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); /** @@ -171,6 +181,7 @@ class EPaperBase : public Display, uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; + uint8_t effective_transform_{}; uint8_t update_count_{}; // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index d10022c4ac..ee117304c4 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -15,7 +15,11 @@ void EPaperMono::refresh_screen(bool partial) { void EPaperMono::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); - this->command(0x10); + if (this->is_using_partial_update_()) { + this->cmd_data(0x10, {0x00}); // sleep in power on mode + } else { + this->cmd_data(0x10, {0x03}); // deep sleep + } } bool EPaperMono::reset() { @@ -27,6 +31,14 @@ bool EPaperMono::reset() { } void EPaperMono::set_window() { + // if not using partial update, the display will go into deep sleep, so must rewrite entire + // buffer since the display RAM will not retain contents + if (!this->is_using_partial_update_()) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } // round x-coordinates to byte boundaries this->x_low_ &= ~7; this->x_high_ += 7; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0ce1117262..f27690c97b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -44,11 +44,12 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, HexInt +from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.types import ConfigType -from esphome.writer import clean_cmake_cache +from esphome.writer import clean_build, clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS from .const import ( # noqa @@ -97,8 +98,12 @@ CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_RELEASE = "release" +CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" +CONF_SIGNING_KEY = "signing_key" +CONF_SIGNING_SCHEME = "signing_scheme" CONF_SRAM1_AS_IRAM = "sram1_as_iram" CONF_SUBTYPE = "subtype" +CONF_VERIFICATION_KEY = "verification_key" ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" @@ -120,6 +125,27 @@ ASSERTION_LEVELS = { "SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT", } +SIGNING_SCHEMES = { + "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", + "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", +} + +# Chip variants that only support one signing scheme for Secure Boot V2. +# Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. +# Variants not listed in either set support both RSA and ECDSA +# (e.g. C5, C6, H2, P4). New variants should be added to the +# appropriate set if they only support one scheme. +SIGNED_OTA_RSA_ONLY_VARIANTS = { + VARIANT_ESP32, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, +} +SIGNED_OTA_ECC_ONLY_VARIANTS = { + VARIANT_ESP32C2, + VARIANT_ESP32C61, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -962,6 +988,47 @@ def final_validate(config): ) # disable the rollback feature anyway since it can't be used. advanced[CONF_ENABLE_OTA_ROLLBACK] = False + if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): + scheme = signed_ota[CONF_SIGNING_SCHEME] + variant = config[CONF_VARIANT] + scheme_variant_conflicts = { + "ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"), + "rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"), + } + if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[ + 0 + ]: + errs.append( + cv.Invalid( + f"Signing scheme '{scheme}' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", + path=[ + CONF_FRAMEWORK, + CONF_ADVANCED, + CONF_SIGNED_OTA_VERIFICATION, + CONF_SIGNING_SCHEME, + ], + ) + ) + if CONF_OTA not in full_config: + _LOGGER.warning( + "Signed OTA verification is enabled but no OTA component is configured. " + "The initial firmware will be signed but OTA updates won't be possible " + "until an OTA component is added." + ) + if CONF_SIGNING_KEY in signed_ota: + _LOGGER.info( + "Signed OTA verification is enabled. Keep your signing key safe! " + "If you lose the signing key, you will NOT be able to OTA update " + "devices running firmware signed with this key. " + "Without the key, you'll need to reflash via serial." + ) + else: + _LOGGER.info( + "Signed OTA verification is configured with a public verification key. " + "Binaries will NOT be signed automatically during build. " + "You must sign them externally before flashing." + ) if errs: raise cv.MultipleInvalid(errs) @@ -1173,6 +1240,18 @@ FRAMEWORK_SCHEMA = cv.Schema( min=8192, max=32768 ), cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, + cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( + cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional( + CONF_SIGNING_SCHEME, default="rsa3072" + ): cv.one_of(*SIGNING_SCHEMES, lower=True), + } + ), + cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), + ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, @@ -1325,7 +1404,9 @@ CONF_PARTITIONS = "partitions" CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_BOARD): cv.string_strict, + cv.Optional(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_CPU_FREQUENCY): cv.one_of( *FULL_CPU_FREQUENCIES, upper=True ), @@ -1878,6 +1959,32 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) cg.add_define("USE_OTA_ROLLBACK") + # Enable signed app verification without hardware secure boot + if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): + add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) + add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True) + + scheme = signed_ota[CONF_SIGNING_SCHEME] + for key, flag in SIGNING_SCHEMES.items(): + add_idf_sdkconfig_option(flag, scheme == key) + + if CONF_SIGNING_KEY in signed_ota: + # Private key mode — auto-sign binaries during build + add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_SIGNING_KEY", + str(signed_ota[CONF_SIGNING_KEY].resolve()), + ) + else: + # Public key mode — verification only, external signing required + add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + ) + + cg.add_define("USE_OTA_SIGNED_VERIFICATION") + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( @@ -2195,6 +2302,7 @@ def _write_sdkconfig(): if write_file_if_changed(internal_path, contents): # internal changed, update real one write_file_if_changed(sdk_path, contents) + clean_build(clear_pio_cache=False) def _write_idf_component_yml(): diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ecf30d7878..ed61b61936 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -59,6 +59,59 @@ static inline bool is_return_addr(uint32_t addr) { } #endif +// --- Architecture-specific backtrace helpers --- +// These run from IRAM during panic (no flash access). + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +// Walk Xtensa backtrace from an exception frame, writing PCs to out[]. +// Returns number of entries written. +static uint8_t IRAM_ATTR walk_xtensa_backtrace(XtExcFrame *frame, uint32_t *out, uint8_t max) { + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) frame->pc, + .sp = (uint32_t) frame->a1, + .next_pc = (uint32_t) frame->a0, + .exc_frame = frame, + }; + uint8_t count = 0; + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + out[count++] = first_pc; + } + while (count < max && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) + break; + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + out[count++] = pc; + } + } + return count; +} +#endif + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Capture RISC-V backtrace: MEPC + RA from registers, then stack scan. +// Returns total count; *reg_count receives number of register-sourced entries. +static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *out, uint8_t max, uint8_t *reg_count) { + uint8_t count = 0; + if (is_code_addr(frame->mepc)) { + out[count++] = frame->mepc; + } + if (is_code_addr(frame->ra) && frame->ra != frame->mepc) { + out[count++] = frame->ra; + } + *reg_count = count; + auto *scan_start = (uint32_t *) frame->sp; + for (uint32_t i = 0; i < 64 && count < max; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != frame->mepc && val != frame->ra) { + out[count++] = val; + } + } + return count; +} +#endif + // Raw crash data written by the panic handler wrapper. // Lives in .noinit so it survives software reset but contains garbage after power cycle. // Validated by magic marker. Static linkage since it's only used within this file. @@ -66,7 +119,7 @@ static inline bool is_return_addr(uint32_t addr) { // Magic is second to validate the data. Remaining fields can change between versions. // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. -static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_DATA_VERSION = 2; struct RawCrashData { uint32_t version; uint32_t magic; @@ -77,6 +130,13 @@ struct RawCrashData { uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) uint32_t backtrace[MAX_BACKTRACE]; uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint8_t crashed_core; +#if SOC_CPU_CORES_NUM > 1 + static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores"); + uint8_t other_backtrace_count; + uint8_t other_reg_frame_count; + uint32_t other_backtrace[MAX_BACKTRACE]; +#endif }; static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -100,13 +160,28 @@ void crash_handler_read_and_clear() { s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT if (s_raw_crash_data.pseudo_excause > 1) s_raw_crash_data.pseudo_excause = 0; + if (s_raw_crash_data.crashed_core >= SOC_CPU_CORES_NUM) + s_raw_crash_data.crashed_core = 0; +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.other_backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.other_reg_frame_count > s_raw_crash_data.other_backtrace_count) + s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; +#endif } - // Clear magic regardless so we don't re-report on next normal reboot - s_raw_crash_data.magic = 0; + // Don't clear magic here — crash data must survive OTA rollback reboots. + // Magic is cleared by crash_handler_clear() after an API client receives the data. } bool crash_handler_has_data() { return s_crash_data_valid; } +void crash_handler_clear() { + // Only clear the magic so data doesn't survive the next reboot. + // Keep s_crash_data_valid so crash_handler_log() still works for + // additional API clients connecting during this boot session. + s_raw_crash_data.magic = 0; +} + // Look up the exception cause as a human-readable string. // Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays // not exposed via any public API. @@ -212,6 +287,36 @@ static const char *get_exception_type() { return "Unknown"; } +// Log backtrace entries, filtering stack-scanned addresses on RISC-V. +static void log_backtrace(const uint32_t *addrs, uint8_t count, uint8_t reg_frame_count) { + uint8_t bt_num = 0; + for (uint8_t i = 0; i < count; i++) { + uint32_t addr = addrs[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= reg_frame_count && !is_return_addr(addr)) + continue; + const char *source = (i < reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } +} + +// Append backtrace addresses to the addr2line hint buffer. +static int append_addrs_to_hint(char *buf, int size, int pos, const uint32_t *addrs, uint8_t count, + uint8_t reg_frame_count) { + for (uint8_t i = 0; i < count && pos < size - 12; i++) { + uint32_t addr = addrs[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(buf + pos, size - pos, " 0x%08" PRIX32, addr); + } + return pos; +} + // Intentionally uses separate ESP_LOGE calls per line instead of combining into // one multi-line log message. This ensures each address appears as its own line // on the serial console, making it possible to see partial output if the device @@ -228,33 +333,28 @@ void crash_handler_log() { } else { ESP_LOGE(TAG, " Reason: %s", get_exception_type()); } + ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core); ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); - uint8_t bt_num = 0; - for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { - uint32_t addr = s_raw_crash_data.backtrace[i]; -#if CONFIG_IDF_TARGET_ARCH_RISCV - // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. - if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) - continue; -#endif -#if CONFIG_IDF_TARGET_ARCH_RISCV - const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; -#else - const char *source = "backtrace"; -#endif - ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count); + +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + int other_core = 1 - s_raw_crash_data.crashed_core; + ESP_LOGE(TAG, " Other core (%d) backtrace:", other_core); + log_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, + s_raw_crash_data.other_reg_frame_count); } +#endif + // Build addr2line hint with all captured addresses for easy copy-paste char hint[256]; int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { - uint32_t addr = s_raw_crash_data.backtrace[i]; -#if CONFIG_IDF_TARGET_ARCH_RISCV - if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) - continue; + pos = append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); +#if SOC_CPU_CORES_NUM > 1 + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); #endif - pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); - } ESP_LOGE(TAG, "%s", hint); } @@ -276,68 +376,54 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.reg_frame_count = 0; s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + s_raw_crash_data.crashed_core = (uint8_t) info->core; +#if SOC_CPU_CORES_NUM > 1 + s_raw_crash_data.other_backtrace_count = 0; + s_raw_crash_data.other_reg_frame_count = 0; +#endif #if CONFIG_IDF_TARGET_ARCH_XTENSA // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; s_raw_crash_data.cause = xt_frame->exccause; - esp_backtrace_frame_t bt_frame = { - .pc = (uint32_t) xt_frame->pc, - .sp = (uint32_t) xt_frame->a1, - .next_pc = (uint32_t) xt_frame->a0, - .exc_frame = xt_frame, - }; - - uint8_t count = 0; - // First frame PC - uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); - if (is_code_addr(first_pc)) { - s_raw_crash_data.backtrace[count++] = first_pc; - } - // Walk remaining frames - while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { - if (!esp_backtrace_get_next_frame(&bt_frame)) { - break; - } - uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); - if (is_code_addr(pc)) { - s_raw_crash_data.backtrace[count++] = pc; - } - } - s_raw_crash_data.backtrace_count = count; + s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } +#if SOC_CPU_CORES_NUM > 1 + // Capture the other core's backtrace from the global frame array. + // Both cores save their frames to g_exc_frames[] before esp_panic_handler + // is called, so the other core's frame is available here. + if (info->core >= 0 && info->core < SOC_CPU_CORES_NUM) { + int other_core = 1 - info->core; + auto *other_frame = (XtExcFrame *) g_exc_frames[other_core]; + if (other_frame != nullptr) { + s_raw_crash_data.other_backtrace_count = + walk_xtensa_backtrace(other_frame, s_raw_crash_data.other_backtrace, MAX_BACKTRACE); + } + } +#endif + #elif CONFIG_IDF_TARGET_ARCH_RISCV // RISC-V: capture MEPC + RA, then scan stack for code addresses if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; s_raw_crash_data.cause = rv_frame->mcause; - uint8_t count = 0; - - // Save MEPC (fault PC) and RA (return address) - if (is_code_addr(rv_frame->mepc)) { - s_raw_crash_data.backtrace[count++] = rv_frame->mepc; - } - if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { - s_raw_crash_data.backtrace[count++] = rv_frame->ra; - } - - // Track how many entries came from registers (MEPC/RA) so we can - // skip return-address validation for them at log time. - s_raw_crash_data.reg_frame_count = count; - - // Scan stack for code addresses — captures broadly during panic, - // filtered by is_return_addr() at log time when flash is accessible. - auto *scan_start = (uint32_t *) rv_frame->sp; - for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { - uint32_t val = scan_start[i]; - if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { - s_raw_crash_data.backtrace[count++] = val; - } - } - s_raw_crash_data.backtrace_count = count; + s_raw_crash_data.backtrace_count = + capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } + +#if SOC_CPU_CORES_NUM > 1 + // Capture the other core's backtrace from the global frame array. + if (info->core >= 0 && info->core < SOC_CPU_CORES_NUM) { + int other_core = 1 - info->core; + auto *other_frame = (RvExcFrame *) g_exc_frames[other_core]; + if (other_frame != nullptr) { + s_raw_crash_data.other_backtrace_count = capture_riscv_backtrace( + other_frame, s_raw_crash_data.other_backtrace, MAX_BACKTRACE, &s_raw_crash_data.other_reg_frame_count); + } + } +#endif #endif // Write version and magic last — ensures all data is written before we mark it valid diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 97a4d4e116..c5e7d145ec 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,12 +4,18 @@ namespace esphome::esp32 { -/// Read crash data from NOINIT memory and clear the magic marker. +/// Read and validate crash data from NOINIT memory. +/// Does not clear the magic marker — call crash_handler_clear() after +/// the data has been delivered to an API client so it survives OTA rollback reboots. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Clear the magic marker and mark crash data as consumed. +/// Call after the data has been delivered to an API client. +void crash_handler_clear(); + /// Returns true if crash data was found this boot. bool crash_handler_has_data(); diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index 993606d9de..bd7bb9e220 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -26,8 +26,8 @@ _LOGGER = logging.getLogger(__name__) def esp32_c6_validate_gpio_pin(value: int) -> int: - if value < 0 or value > 23: - raise cv.Invalid(f"Invalid pin number: {value} (must be 0-23)") + if value < 0 or value > 30: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-30)") if value in _ESP32C6_SPI_PSRAM_PINS: raise cv.Invalid( f"This pin cannot be used on ESP32-C6s and is already used by the SPI/PSRAM interface (function: {_ESP32C6_SPI_PSRAM_PINS[value]})" @@ -47,8 +47,8 @@ def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]: mode = value[CONF_MODE] is_input = mode[CONF_INPUT] - if num < 0 or num > 23: - raise cv.Invalid(f"Invalid pin number: {num} (must be 0-23)") + if num < 0 or num > 30: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-30)") if is_input: # All ESP32 pins support input mode pass diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 5ef5860687..8d13214259 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -8,6 +8,99 @@ import shutil # noqa: E402 from glob import glob # noqa: E402 +def _parse_sdkconfig(sdkconfig_path): + """Parse sdkconfig file and return a dict of CONFIG_ options.""" + options = {} + try: + for line in sdkconfig_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + # Strip surrounding quotes from string values + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + options[key] = value + except FileNotFoundError: + pass + return options + + +def sign_firmware(source, target, env): + """ + Sign the firmware binary using espsecure.py if signed OTA verification is enabled. + Reads signing configuration from sdkconfig. + """ + build_dir = pathlib.Path(env.subst("$BUILD_DIR")) + project_dir = pathlib.Path(env.subst("$PROJECT_DIR")) + pioenv = env.subst("$PIOENV") + sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}") + + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") != "y": + return + + if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") != "y": + print("Signed OTA verification enabled but build-time signing disabled.") + print("You must sign the firmware externally before flashing.") + return + + signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY") + if not signing_key: + print("Error: CONFIG_SECURE_BOOT_SIGNING_KEY not set in sdkconfig") + env.Exit(1) + return + + signing_key_path = pathlib.Path(signing_key) + if not signing_key_path.exists(): + print(f"Error: Signing key not found: {signing_key_path}") + env.Exit(1) + return + + # ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes), + # so the espsecure signature version is always 2. + sign_version = "2" + + firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin" + firmware_path = build_dir / firmware_name + + if not firmware_path.exists(): + print(f"Error: Firmware binary not found: {firmware_path}") + env.Exit(1) + return + + python_exe = f'"{env.subst("$PYTHONEXE")}"' + unsigned_path = firmware_path.with_suffix(".unsigned.bin") + + # Keep a copy of the unsigned binary + shutil.copyfile(str(firmware_path), str(unsigned_path)) + + cmd = [ + python_exe, + "-m", + "espsecure", + "sign-data", + "--version", + sign_version, + "--keyfile", + str(signing_key_path), + "--output", + str(firmware_path), + str(unsigned_path), + ] + + print(f"Signing firmware with key: {signing_key_path.name}") + result = env.Execute( + env.VerboseAction(" ".join(cmd), "Signing firmware with espsecure") + ) + + if result == 0: + print("Successfully signed firmware") + else: + print(f"Error: espsecure sign_data failed with code {result}") + # Restore unsigned binary on failure + shutil.copyfile(str(unsigned_path), str(firmware_path)) + env.Exit(1) + + def merge_factory_bin(source, target, env): """ Merges all flash sections into a single .factory.bin using esptool. @@ -124,7 +217,8 @@ def esp32_copy_ota_bin(source, target, env): print(f"Copied firmware to {new_file_name}") -# Run merge first, then ota copy second +# Run signing first, then merge, then ota copy +env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_copy_ota_bin) # noqa: F821 diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2e5e358753..79d05049bf 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -7,7 +7,6 @@ from typing import Any from esphome import automation import esphome.codegen as cg -from esphome.components import socket from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv @@ -374,14 +373,14 @@ def bt_uuid(value): value = in_value.upper() if len(value) == len(bt_uuid16_format): - pattern = re.compile("^[A-F|0-9]{4,}$") + pattern = re.compile("^[A-F0-9]{4,}$") if not pattern.match(value): raise cv.Invalid( f"Invalid hexadecimal value for 16 bit UUID format: '{in_value}'" ) return value if len(value) == len(bt_uuid32_format): - pattern = re.compile("^[A-F|0-9]{8,}$") + pattern = re.compile("^[A-F0-9]{8,}$") if not pattern.match(value): raise cv.Invalid( f"Invalid hexadecimal value for 32 bit UUID format: '{in_value}'" @@ -389,7 +388,7 @@ def bt_uuid(value): return value if len(value) == len(bt_uuid128_format): pattern = re.compile( - "^[A-F|0-9]{8,}-[A-F|0-9]{4,}-[A-F|0-9]{4,}-[A-F|0-9]{4,}-[A-F|0-9]{12,}$" + "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" ) if not pattern.match(value): raise cv.Invalid( @@ -592,11 +591,6 @@ async def to_code(config): cg.add(var.set_name(name)) await cg.register_component(var, config) - # BLE uses the socket wake_loop_threadsafe() mechanism to wake the main loop from BLE tasks - # This enables low-latency (~12μs) BLE event processing instead of waiting for - # select() timeout (0-16ms). The wake socket is shared across all components. - socket.require_wake_loop_threadsafe() - # Define max connections for use in C++ code (e.g., ble_server.h) max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 2cd2ec67f7..0280439731 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -399,8 +399,17 @@ void ESP32BLE::loop() { return; } +#ifdef USE_ESP32_BLE_ADVERTISING + if (this->advertising_ != nullptr) { + this->advertising_->loop(); + } +#endif + BLEEvent *ble_event = this->ble_events_.pop(); - while (ble_event != nullptr) { + if (ble_event == nullptr) + return; + + do { switch (ble_event->type_) { #if defined(USE_ESP32_BLE_SERVER) && defined(ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT) case BLEEvent::GATTS: { @@ -488,15 +497,11 @@ void ESP32BLE::loop() { } // Return the event to the pool this->ble_event_pool_.release(ble_event); - ble_event = this->ble_events_.pop(); - } -#ifdef USE_ESP32_BLE_ADVERTISING - if (this->advertising_ != nullptr) { - this->advertising_->loop(); - } -#endif + } while ((ble_event = this->ble_events_.pop()) != nullptr); - // Log dropped events periodically + // Log dropped events - only reachable when events were processed. + // Drops only occur when the queue is full, and only this loop drains it, + // so if pop() returned nullptr above we can skip this check (saves a memw). uint16_t dropped = this->ble_events_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u BLE events due to buffer overflow", dropped); @@ -594,9 +599,7 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa GAP_SECURITY_EVENTS: enqueue_ble_event(event, param); // Wake up main loop to process security event immediately -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif return; // Ignore these GAP events as they are not relevant for our use case @@ -617,9 +620,7 @@ void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gat esp_ble_gatts_cb_param_t *param) { enqueue_ble_event(event, gatts_if, param); // Wake up main loop to process GATT event immediately -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif } #endif @@ -628,9 +629,7 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat esp_ble_gattc_cb_param_t *param) { enqueue_ble_event(event, gattc_if, param); // Wake up main loop to process GATT event immediately -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif } #endif diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index e2e790164e..8052c13596 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -10,11 +10,7 @@ AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] esp32_ble_beacon_ns = cg.esphome_ns.namespace("esp32_ble_beacon") -ESP32BLEBeacon = esp32_ble_beacon_ns.class_( - "ESP32BLEBeacon", - cg.Component, - cg.Parented.template(esp32_ble.ESP32BLE), -) +ESP32BLEBeacon = esp32_ble_beacon_ns.class_("ESP32BLEBeacon", cg.Component) CONF_MAJOR = "major" CONF_MINOR = "minor" CONF_MIN_INTERVAL = "min_interval" diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index e16c413179..44a7133454 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -35,7 +35,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component, public Parented { +class ESP32BLEBeacon : public Component { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 57106cd93b..7bf3092a4e 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -307,24 +307,30 @@ def final_validate_config(config): # Check if all characteristics that require notifications have the notify property set for char_id in CORE.data.get(DOMAIN, {}).get(KEY_NOTIFY_REQUIRED, set()): # Look for the characteristic in the configuration - char_config = [ + matches = [ char_conf for service_conf in config[CONF_SERVICES] for char_conf in service_conf[CONF_CHARACTERISTICS] if char_conf[CONF_ID] == char_id - ][0] + ] + if not matches: + continue + char_config = matches[0] if not char_config[CONF_NOTIFY]: raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has notify actions and the {CONF_NOTIFY} property is not set" ) for char_id in CORE.data.get(DOMAIN, {}).get(KEY_SET_VALUE, set()): # Look for the characteristic in the configuration - char_config = [ + matches = [ char_conf for service_conf in config[CONF_SERVICES] for char_conf in service_conf[CONF_CHARACTERISTICS] if char_conf[CONF_ID] == char_id - ][0] + ] + if not matches: + continue + char_config = matches[0] if isinstance(char_config.get(CONF_VALUE, {}).get(CONF_DATA), cv.Lambda): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value" diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b9c4c28ccf..d758b400c4 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -378,7 +378,8 @@ async def esp32_ble_tracker_start_scan_action_to_code( ): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - cg.add(var.set_continuous(config[CONF_CONTINUOUS])) + template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) + cg.add(var.set_continuous(template_)) return var diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 66af321e4e..5165956806 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -2,7 +2,7 @@ import logging from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c, socket +from esphome.components import i2c from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv @@ -29,7 +29,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["camera", "socket"] +AUTO_LOAD = ["camera"] DEPENDENCIES = ["esp32"] esp32_camera_ns = cg.esphome_ns.namespace("esp32_camera") @@ -370,7 +370,6 @@ SETTERS = { async def to_code(config): cg.add_define("USE_CAMERA") - socket.require_wake_loop_threadsafe() var = cg.new_Pvariable(config[CONF_ID]) await setup_entity(var, config, "camera") await cg.register_component(var, config) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 085feb8c8a..a7546476d8 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -521,11 +521,9 @@ void ESP32Camera::framebuffer_task(void *pv) { camera_fb_t *framebuffer = esp_camera_fb_get(); xQueueSend(that->framebuffer_get_queue_, &framebuffer, portMAX_DELAY); // Only wake the main loop if there's a pending request to consume the frame -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) if (that->has_requested_image_()) { App.wake_loop_threadsafe(); } -#endif // return is no-op for config with 1 fb xQueueReceive(that->framebuffer_return_queue_, &framebuffer, portMAX_DELAY); esp_camera_fb_return(framebuffer); diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 3f9185745d..1619a845d8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -4,95 +4,245 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 import esphome.config_validation as cv -from esphome.const import CONF_CLK_PIN, CONF_RESET_PIN, CONF_VARIANT +from esphome.const import ( + CONF_CLK_PIN, + CONF_CS_PIN, + CONF_FREQUENCY, + CONF_MISO_PIN, + CONF_MOSI_PIN, + CONF_RESET_PIN, + CONF_TYPE, + CONF_VARIANT, +) from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] CONF_ACTIVE_HIGH = "active_high" +CONF_BUS_WIDTH = "bus_width" CONF_CMD_PIN = "cmd_pin" CONF_D0_PIN = "d0_pin" CONF_D1_PIN = "d1_pin" CONF_D2_PIN = "d2_pin" CONF_D3_PIN = "d3_pin" -CONF_SLOT = "slot" +CONF_DATA_READY_ACTIVE_HIGH = "data_ready_active_high" +CONF_DATA_READY_PIN = "data_ready_pin" +CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" +CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" +CONF_SLOT = "slot" +CONF_SPI_MODE = "spi_mode" -CONFIG_SCHEMA = cv.All( - cv.Schema( - { - cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), - cv.Required(CONF_ACTIVE_HIGH): cv.boolean, - cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_CMD_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_D0_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_D1_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_D2_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_D3_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_SLOT, default=1): cv.int_range(min=0, max=1), - cv.Optional(CONF_SDIO_FREQUENCY, default="40MHz"): cv.All( - cv.frequency, cv.Range(min=400e3, max=50e6) - ), - } - ), +# Shared fields for both transport modes +BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), + cv.Required(CONF_ACTIVE_HIGH): cv.boolean, + cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + } +) + +SDIO_SCHEMA = BASE_SCHEMA.extend( + { + cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_CMD_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_D0_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_D1_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_D2_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_D3_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_BUS_WIDTH, default=4): cv.one_of(1, 4, int=True), + cv.Optional(CONF_SLOT, default=1): cv.int_range(min=0, max=1), + cv.Optional(CONF_SDIO_FREQUENCY, default="40MHz"): cv.All( + cv.frequency, cv.Range(min=400e3, max=50e6) + ), + } ) -async def to_code(config): - add_define("USE_ESP32_HOSTED") - if config[CONF_ACTIVE_HIGH]: - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH", - True, +def _validate_sdio(config): + if config[CONF_BUS_WIDTH] == 4: + for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN): + if pin not in config: + raise cv.Invalid( + f"{pin} is required when bus_width is 4", + path=[pin], + ) + return config + + +# SPI variant-dependent defaults and limits +_SPI_VARIANT_DEFAULTS = { + "ESP32": {"spi_mode": 2, "frequency": 10, "max_frequency": 10}, + "ESP32C6": {"spi_mode": 3, "frequency": 26, "max_frequency": 40}, +} +_SPI_DEFAULT = {"spi_mode": 3, "frequency": 40, "max_frequency": 40} + +SPI_SCHEMA = BASE_SCHEMA.extend( + { + cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_HANDSHAKE_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_DATA_READY_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_SPI_MODE): cv.int_range(min=0, max=3), + cv.Optional(CONF_FREQUENCY): cv.All(cv.frequency, cv.Range(min=1e6, max=40e6)), + cv.Optional(CONF_HANDSHAKE_ACTIVE_HIGH, default=True): cv.boolean, + cv.Optional(CONF_DATA_READY_ACTIVE_HIGH, default=True): cv.boolean, + } +) + + +def _validate_spi(config): + variant = config[CONF_VARIANT] + defaults = _SPI_VARIANT_DEFAULTS.get(variant, _SPI_DEFAULT) + + if CONF_SPI_MODE not in config: + config[CONF_SPI_MODE] = defaults["spi_mode"] + + if CONF_FREQUENCY not in config: + config[CONF_FREQUENCY] = float(defaults["frequency"] * 1e6) + + freq_mhz = int(config[CONF_FREQUENCY] // 1e6) + if freq_mhz > defaults["max_frequency"]: + raise cv.Invalid( + f"SPI frequency {freq_mhz}MHz exceeds maximum {defaults['max_frequency']}MHz for {variant}", + path=[CONF_FREQUENCY], ) + return config + + +CONFIG_SCHEMA = cv.typed_schema( + { + "sdio": cv.All(SDIO_SCHEMA, _validate_sdio), + "spi": cv.All(SPI_SCHEMA, _validate_spi), + }, + default_type="sdio", +) + + +def _configure_sdio(config): + slot = config[CONF_SLOT] + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", + True, + ) + if config[CONF_BUS_WIDTH] == 1: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_1_BIT_BUS", True) else: - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_LOW", - True, - ) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS", True) esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE", # NOLINT - config[CONF_RESET_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT - True, - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}", - True, - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_{config[CONF_SLOT]}", + f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_{slot}", config[CONF_CLK_PIN], ) esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_{config[CONF_SLOT]}", + f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_{slot}", config[CONF_CMD_PIN], ) esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_{config[CONF_SLOT]}", + f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_{slot}", config[CONF_D0_PIN], ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_{config[CONF_SLOT]}", - config[CONF_D1_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_{config[CONF_SLOT]}", - config[CONF_D2_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_{config[CONF_SLOT]}", - config[CONF_D3_PIN], - ) + if config[CONF_BUS_WIDTH] == 4: + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_{slot}", + config[CONF_D1_PIN], + ) + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_{slot}", + config[CONF_D2_PIN], + ) + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_{slot}", + config[CONF_D3_PIN], + ) esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ", int(config[CONF_SDIO_FREQUENCY] // 1000), ) + +def _configure_spi(config): + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) + # SPI mode is set via per-variant choice options + variant = config[CONF_VARIANT] + mode = config[CONF_SPI_MODE] + suffix = "ESP32" if variant == "ESP32" else "ESP32XX" + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_SPI_PRIV_MODE_{mode}_{suffix}", + True, + ) + # Frequency is set via per-variant options + freq_mhz = int(config[CONF_FREQUENCY] // 1e6) + if variant == "ESP32": + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32", freq_mhz) + elif variant == "ESP32C6": + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32C6", freq_mhz) + else: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32XX", freq_mhz) + # Pin configuration (use HSPI variant as P4/H2 hosts don't have VSPI) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MOSI", config[CONF_MOSI_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MISO", config[CONF_MISO_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CLK", config[CONF_CLK_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CS", config[CONF_CS_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_GPIO_HANDSHAKE", config[CONF_HANDSHAKE_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_GPIO_DATA_READY", config[CONF_DATA_READY_PIN] + ) + # Handshake and data_ready polarity + if config[CONF_HANDSHAKE_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_HIGH", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_LOW", True) + if config[CONF_DATA_READY_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_HIGH", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) + + +async def to_code(config): + add_define("USE_ESP32_HOSTED") + transport = config[CONF_TYPE] + transport_prefix = "SDIO" if transport == "sdio" else "SPI" + + # Reset polarity + if config[CONF_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_HIGH", True + ) + else: + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_LOW", True + ) + # Reset GPIO + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_{transport_prefix}_GPIO_RESET_SLAVE", # NOLINT + config[CONF_RESET_PIN], + ) + # Slave variant # NOLINT + esp32.add_idf_sdkconfig_option( + f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT + True, + ) + + # Transport-specific configuration + if transport == "sdio": + _configure_sdio(config) + else: + _configure_spi(config) + + # Library versions idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" if idf_ver >= cv.Version(5, 5, 0): diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 2081145096..bef7e36470 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -20,6 +20,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed from esphome.types import ConfigType @@ -203,7 +204,9 @@ BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_RESTORE_FROM_FLASH, default=False): cv.boolean, cv.Optional(CONF_EARLY_PIN_INIT, default=True): cv.boolean, @@ -233,6 +236,7 @@ async def to_code(config): cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) + cg.add_define("USE_ESP8266_CRASH_HANDLER") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp new file mode 100644 index 0000000000..91b0cf9082 --- /dev/null +++ b/esphome/components/esp8266/crash_handler.cpp @@ -0,0 +1,235 @@ +#ifdef USE_ESP8266 + +#include "esphome/core/defines.h" +#ifdef USE_ESP8266_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include + +extern "C" { +#include + +// Global reset info struct populated by SDK/Arduino core at boot +extern struct rst_info resetInfo; +} + +// Xtensa windowed-ABI: bits[31:30] encode call type (CALL0=00, CALL4=01, +// CALL8=10, CALL12=11). Mask and force bit 30 to recover the real address. +static constexpr uint32_t XTENSA_ADDR_MASK = 0x3FFFFFFF; +static constexpr uint32_t XTENSA_CODE_BASE = 0x40000000; + +// ESP8266 memory map boundaries for code regions +static constexpr uint32_t IRAM_START = 0x40100000; +static constexpr uint32_t IRAM_END = 0x40108000; // 32KB + +// Linker symbols for the actual firmware IROM section. +// Using these instead of a conservative upper bound (0x40400000) prevents +// false positives from stale stack values beyond the actual flash mapping. +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,readability-identifier-naming,readability-redundant-declaration) +extern void _irom0_text_start(void); +extern void _irom0_text_end(void); +// NOLINTEND(bugprone-reserved-identifier,readability-identifier-naming,readability-redundant-declaration) +} + +// Check if a value looks like a code address in IRAM or flash-mapped IROM. +// IRAM_ATTR as safety net — normally inlined into custom_crash_callback, but +// ensures correctness if the compiler ever chooses not to inline. +static inline bool IRAM_ATTR is_code_addr(uint32_t val) { + uint32_t addr = (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; + return (addr >= IRAM_START && addr < IRAM_END) || + (addr >= (uint32_t) _irom0_text_start && addr < (uint32_t) _irom0_text_end); +} + +// Recover the actual code address from a windowed-ABI return address on the stack. +static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; } + +// RTC user memory layout for crash backtrace data. +// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes). +// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts. +// Store 16 raw candidates, filter to real return addresses at log time. +static constexpr uint8_t RTC_CRASH_BASE = 174; +static constexpr size_t MAX_BACKTRACE = 16; + +// Magic word packs sentinel, version, and count into one uint32_t: +// bits[31:16] = sentinel +// bits[15:8] = version +// bits[7:0] = backtrace count +static constexpr uint8_t CRASH_SENTINEL_BITS = 16; +static constexpr uint8_t CRASH_VERSION_BITS = 8; + +static constexpr uint16_t CRASH_SENTINEL_VALUE = 0xDEAD; +static constexpr uint8_t CRASH_VERSION_VALUE = 1; + +static constexpr uint32_t CRASH_SENTINEL = static_cast(CRASH_SENTINEL_VALUE) << CRASH_SENTINEL_BITS; +static constexpr uint32_t CRASH_VERSION = static_cast(CRASH_VERSION_VALUE) << CRASH_VERSION_BITS; +static constexpr uint32_t CRASH_SENTINEL_MASK = static_cast(0xFFFF) << CRASH_SENTINEL_BITS; +static constexpr uint32_t CRASH_VERSION_MASK = static_cast(0xFF) << CRASH_VERSION_BITS; +static constexpr uint32_t CRASH_COUNT_MASK = 0xFF; + +// Struct layout: 18 RTC blocks (72 bytes): +// [0] = magic (sentinel | version | count) +// [1..16] = up to 16 code addresses from stack scanning +// [17] = epc1 at crash time (to skip duplicates at log time) +struct RtcCrashData { + uint32_t magic; + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t epc1; // Fault PC, used to filter duplicates +}; +static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks"); + +namespace esphome::esp8266 { + +static const char *const TAG = "esp8266"; + +static inline bool is_crash_reason(uint32_t reason) { + return reason == REASON_WDT_RST || reason == REASON_EXCEPTION_RST || reason == REASON_SOFT_WDT_RST; +} + +bool crash_handler_has_data() { return is_crash_reason(resetInfo.reason); } + +// Xtensa exception cause names for the LX106 core (ESP8266). +// Only includes causes that can actually occur on the LX106 — it has no MMU, +// no TLB, no PIF, and no privilege levels, so causes 12-18 and 24-26 are +// impossible and omitted. The numeric cause is always logged as fallback. +// Uses if-else with LOG_STR to avoid CSWTCH jump tables (RAM on ESP8266). +static const LogString *get_exception_cause(uint32_t cause) { + if (cause == 0) + return LOG_STR("IllegalInst"); + if (cause == 2) + return LOG_STR("InstFetchErr"); + if (cause == 3) + return LOG_STR("LoadStoreErr"); + if (cause == 4) + return LOG_STR("Level1Int"); + if (cause == 6) + return LOG_STR("DivByZero"); + if (cause == 9) + return LOG_STR("Alignment"); + if (cause == 20) + return LOG_STR("InstFetchProhibit"); + if (cause == 28) + return LOG_STR("LoadProhibit"); + if (cause == 29) + return LOG_STR("StoreProhibit"); + return nullptr; +} + +static const LogString *get_reset_reason(uint32_t reason) { + if (reason == REASON_WDT_RST) + return LOG_STR("Hardware WDT"); + if (reason == REASON_EXCEPTION_RST) + return LOG_STR("Exception"); + if (reason == REASON_SOFT_WDT_RST) + return LOG_STR("Soft WDT"); + return LOG_STR("Unknown"); +} + +// Read backtrace from RTC user memory into caller-provided buffer. +// Returns the number of valid backtrace entries (0 if no data found). +static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { + RtcCrashData rtc_data; + if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) + return 0; + uint32_t magic = rtc_data.magic; + if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION) + return 0; + uint8_t raw_count = magic & CRASH_COUNT_MASK; + if (raw_count > MAX_BACKTRACE) + raw_count = MAX_BACKTRACE; + // Skip any that match epc1 (already reported as the fault PC). + // Note: we cannot verify CALL instructions at addr-3 on ESP8266 because + // reading from IROM causes LoadStoreError due to flash cache conflicts + // (the reading code and target can share a direct-mapped cache line). + // The linker-symbol IROM bounds already eliminate most false positives. + uint8_t out = 0; + for (uint8_t i = 0; i < raw_count && out < max_entries; i++) { + uint32_t addr = rtc_data.backtrace[i]; + if (addr != rtc_data.epc1) + backtrace[out++] = addr; + } + return out; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!is_crash_reason(resetInfo.reason)) + return; + + // Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost). + // Both resetInfo and RTC data survive until the next reset, so this can be + // called multiple times (logger init + API subscribe) with the same result. + uint32_t backtrace[MAX_BACKTRACE]; + uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific + // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match + // the Arduino core's postmortem handler behavior. + static constexpr uint32_t EXCCAUSE_ILLEGAL_INSTRUCTION = 0; + static constexpr uint32_t EXCCAUSE_INTEGER_DIVIDE_BY_ZERO = 6; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_1 = 0x4000dce5; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_2 = 0x4000dd3d; + uint32_t exccause = resetInfo.exccause; + if (exccause == EXCCAUSE_ILLEGAL_INSTRUCTION && + (resetInfo.epc1 == ROM_DIV_ZERO_ADDR_1 || resetInfo.epc1 == ROM_DIV_ZERO_ADDR_2)) { + exccause = EXCCAUSE_INTEGER_DIVIDE_BY_ZERO; + } + const LogString *cause = get_exception_cause(exccause); + if (cause != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s (exccause=%" PRIu32 ")", LOG_STR_ARG(get_reset_reason(resetInfo.reason)), + LOG_STR_ARG(cause), exccause); + } else { + ESP_LOGE(TAG, " Reason: %s (exccause=%" PRIu32 ")", LOG_STR_ARG(get_reset_reason(resetInfo.reason)), exccause); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32, resetInfo.epc1); + if (resetInfo.reason == REASON_EXCEPTION_RST) { + ESP_LOGE(TAG, " EXCVADDR: 0x%08" PRIX32, resetInfo.excvaddr); + } + for (uint8_t i = 0; i < bt_count; i++) { + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32, i, backtrace[i]); + } +} + +} // namespace esphome::esp8266 + +// --- Custom crash callback --- +// Overrides the weak custom_crash_callback() from Arduino core's +// core_esp8266_postmortem.cpp. Called during exception handling before +// the device restarts. We scan the full stack for code addresses and store +// them in RTC user memory (which survives software reset). +extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint32_t stack, uint32_t stack_end) { + // No zero-init — only magic, epc1, and backtrace[0..count-1] are read. + // Saves the IRAM cost of a 72-byte zero-init loop. + RtcCrashData data; // NOLINT(cppcoreguidelines-pro-type-member-init) + uint8_t count = 0; + + // Stack pointer from the Xtensa exception frame is always 4-byte aligned. + auto *scan = (uint32_t *) stack; // NOLINT(performance-no-int-to-ptr) + auto *end = (uint32_t *) stack_end; // NOLINT(performance-no-int-to-ptr) + uint32_t epc1 = rst_info->epc1; + + for (; scan < end && count < MAX_BACKTRACE; scan++) { + uint32_t val = *scan; + if (is_code_addr(val)) { + uint32_t addr = recover_code_addr(val); + // Skip epc1 — already reported as the fault PC + if (addr != epc1) + data.backtrace[count++] = addr; + } + } + + data.epc1 = epc1; + data.magic = CRASH_SENTINEL | CRASH_VERSION | count; + + system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data)); +} + +#endif // USE_ESP8266_CRASH_HANDLER +#endif // USE_ESP8266 diff --git a/esphome/components/esp8266/crash_handler.h b/esphome/components/esp8266/crash_handler.h new file mode 100644 index 0000000000..ea3683b834 --- /dev/null +++ b/esphome/components/esp8266/crash_handler.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266_CRASH_HANDLER + +namespace esphome::esp8266 { + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if the previous boot was a crash (exception, WDT, or soft WDT). +bool crash_handler_has_data(); + +} // namespace esphome::esp8266 + +#endif // USE_ESP8266_CRASH_HANDLER +#endif // USE_ESP8266 diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 43508afaf9..64be4a6495 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -155,7 +155,7 @@ ESP8266_PIN_SCHEMA = cv.All( @dataclass class PinInitialState: - mode = 255 + mode: int = 255 level: int = 255 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 906fed2b29..f444f03555 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -19,12 +19,13 @@ static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; -// RTC memory layout for preferences: -// - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 96-127) -// - Normal region: RTC words 32-127 (mapped from preference offset 0-95) +// RTC memory layout: +// - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109) +// - Normal region: RTC words 32-109 (mapped from preference offset 0-77) +// - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data) static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot -static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 96; // Words 32-127 for normal prefs -static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 128 +static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs +static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110 // Maximum preference size in words (limited by uint8_t length_words field) static constexpr uint32_t MAX_PREFERENCE_WORDS = 255; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index b9b6dcc95a..f119a6ba9f 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -62,6 +62,6 @@ async def to_code(config) -> None: async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 972d2b2b8d..af9b8ee19a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -262,7 +262,7 @@ void ESPHomeOTAComponent::handle_data_() { /// BSD sockets (ESP32): setblocking(true) makes read/write block /// lwip sockets (LT): setblocking(true) makes read/write block /// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses - /// socket_delay()/socket_wake() in read(); + /// wakeable_delay() in read(); /// write() always returns immediately ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 00703bc228..a9624734d0 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,6 +1,6 @@ from esphome import automation, core import esphome.codegen as cg -from esphome.components import socket, wifi +from esphome.components import wifi from esphome.components.udp import CONF_ON_RECEIVE import esphome.config_validation as cv from esphome.const import ( @@ -17,7 +17,7 @@ from esphome.core import HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] -AUTO_LOAD = ["socket"] + byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) @@ -124,10 +124,6 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # ESP-NOW uses wake_loop_threadsafe() to wake the main loop from ESP-NOW callbacks - # This enables low-latency event processing instead of waiting for select() timeout - socket.require_wake_loop_threadsafe() - cg.add_define("USE_ESPNOW") if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) @@ -162,15 +158,15 @@ def validate_peer(value): def _validate_raw_data(value): if isinstance(value, str): - if len(value) >= MAX_ESPNOW_PACKET_SIZE: + if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( - f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" + f"'{CONF_DATA}' must be at most {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" ) return value if isinstance(value, list): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( - f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" + f"'{CONF_DATA}' must be at most {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" ) return cv.Schema([cv.hex_uint8_t])(value) raise cv.Invalid( @@ -249,7 +245,7 @@ async def send_action( data = config.get(CONF_DATA, []) if isinstance(data, str): - data = [cg.RawExpression(f"'{c}'") for c in data] + data = list(data.encode()) templ = await cg.templatable(data, args, byte_vector, byte_vector) cg.add(var.set_data(templ)) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 0dc0f12e7e..282287ca83 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -92,10 +92,8 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW send event instead of waiting for select() timeout -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + // Wake main loop immediately to process ESP-NOW send event App.wake_loop_threadsafe(); -#endif } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { @@ -115,10 +113,8 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW receive event instead of waiting for select() timeout -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + // Wake main loop immediately to process ESP-NOW receive event App.wake_loop_threadsafe(); -#endif } ESPNowComponent::ESPNowComponent() { global_esp_now = this; } diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 17459cabb6..10f9a73863 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -104,6 +104,8 @@ CONF_CLK_MODE = "clk_mode" CONF_POWER_PIN = "power_pin" CONF_PHY_REGISTERS = "phy_registers" +CONF_INTERFACE = "interface" + CONF_CLOCK_SPEED = "clock_speed" EthernetType = ethernet_ns.enum("EthernetType") @@ -121,6 +123,8 @@ ETHERNET_TYPES = { "DM9051": EthernetType.ETHERNET_TYPE_DM9051, "LAN8670": EthernetType.ETHERNET_TYPE_LAN8670, "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, + "W6100": EthernetType.ETHERNET_TYPE_W6100, + "W6300": EthernetType.ETHERNET_TYPE_W6300, } # PHY types that need compile-time defines for conditional compilation @@ -138,6 +142,8 @@ _PHY_TYPE_TO_DEFINE = { "DM9051": "USE_ETHERNET_DM9051", "LAN8670": "USE_ETHERNET_LAN8670", "ENC28J60": "USE_ETHERNET_ENC28J60", + "W6100": "USE_ETHERNET_W6100", + "W6300": "USE_ETHERNET_W6300", } @@ -168,12 +174,14 @@ _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"} -# RP2040-supported SPI ethernet types -RP2040_SPI_ETHERNET_TYPES = {"W5100", "W5500", "ENC28J60"} +# RP2040-supported ethernet types (SPI and PIO QSPI) +RP2040_ETHERNET_TYPES = {"W5100", "W5500", "W6100", "W6300", "ENC28J60"} _RP2040_SPI_LIBRARIES = { "W5100": "lwIP_w5100", "W5500": "lwIP_w5500", "ENC28J60": "lwIP_enc28j60", + "W6100": "lwIP_w6100", + "W6300": "lwIP_w6300", } SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) @@ -191,6 +199,13 @@ CLK_MODES_DEPRECATED = { "GPIO17_OUT": ("CLK_OUT", 17), } +spi_host_device_t = cg.global_ns.enum("spi_host_device_t") + +SPI_INTERFACE_MAP = { + "spi2": spi_host_device_t.SPI2_HOST, + "spi3": spi_host_device_t.SPI3_HOST, +} + MANUAL_IP_SCHEMA = cv.Schema( { cv.Required(CONF_STATIC_IP): cv.ipv4address, @@ -225,6 +240,24 @@ def _is_framework_spi_polling_mode_supported() -> bool: return False +def _validate_spi_interface(config: ConfigType) -> ConfigType: + """Set default SPI interface or validate user choice against the variant.""" + if not CORE.is_esp32: + return config + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant + from esphome.components.spi import get_hw_interface_list + + has_spi3 = "spi3" in sum(get_hw_interface_list(), []) + if CONF_INTERFACE not in config: + # Only classic ESP32 defaults to spi3; all others default to spi2 + config[CONF_INTERFACE] = ( + "spi3" if get_esp32_variant() == VARIANT_ESP32 else "spi2" + ) + elif config[CONF_INTERFACE] == "spi3" and not has_spi3: + raise cv.Invalid("Interface 'spi3' is not available on this variant.") + return config + + def _validate(config): if CONF_USE_ADDRESS not in config: if CONF_MANUAL_IP in config: @@ -301,9 +334,9 @@ def _validate(config): f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " f"on ESP32 classic and ESP32-P4, not {variant}" ) - elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_SPI_ETHERNET_TYPES: + elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_ETHERNET_TYPES: raise cv.Invalid( - f"Only {', '.join(sorted(RP2040_SPI_ETHERNET_TYPES))} are supported on RP2040, " + f"Only {', '.join(sorted(RP2040_ETHERNET_TYPES))} are supported on RP2040, " f"not {config[CONF_TYPE]}" ) return config @@ -368,6 +401,10 @@ SPI_SCHEMA = cv.All( cv.frequency, cv.int_range(int(8e6), int(80e6)), ), + cv.Optional(CONF_INTERFACE): cv.All( + cv.only_on_esp32, + cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), + ), # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() cv.Optional(CONF_POLLING_INTERVAL): cv.All( cv.only_on_esp32, @@ -378,6 +415,7 @@ SPI_SCHEMA = cv.All( ), ), cv.only_on([Platform.ESP32, Platform.RP2040]), + _validate_spi_interface, ) CONFIG_SCHEMA = cv.All( @@ -395,6 +433,8 @@ CONFIG_SCHEMA = cv.All( "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "ENC28J60": SPI_SCHEMA, + "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), + "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "LAN8670": RMII_SCHEMA, }, upper=True, @@ -408,37 +448,18 @@ def _final_validate_spi(config): return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: return - from esphome.components.esp32 import ( - VARIANT_ESP32C3, - VARIANT_ESP32C5, - VARIANT_ESP32C6, - VARIANT_ESP32C61, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - get_esp32_variant, - ) from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface if spi_configs := fv.full_config.get().get(CONF_SPI): - variant = get_esp32_variant() - if variant in ( - VARIANT_ESP32C3, - VARIANT_ESP32C5, - VARIANT_ESP32C6, - VARIANT_ESP32C61, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - ): - spi_host = "SPI2_HOST" - else: - spi_host = "SPI3_HOST" + # get_spi_interface() returns strings like "SPI2_HOST" + spi_host = f"{config[CONF_INTERFACE].upper()}_HOST" for spi_conf in spi_configs: if (index := spi_conf.get(CONF_INTERFACE_INDEX)) is not None: interface = get_spi_interface(index) if interface == spi_host: raise cv.Invalid( - f"`spi` component is using interface '{interface}'. " - f"To use {config[CONF_TYPE]}, you must change the `interface` on the `spi` component.", + f"The `ethernet` and `spi` components are both using interface '{interface}'. " + f"To use {config[CONF_TYPE]}, change the `interface` on either `ethernet:` or `spi:`." ) @@ -528,6 +549,8 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add(var.set_clock_speed(config[CONF_CLOCK_SPEED])) cg.add_define("USE_ETHERNET_SPI") + + cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 # ENC28J60 was never built-in to IDF, so it has no Kconfig option diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index c6e37d01ea..3a87842315 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -11,6 +11,9 @@ #ifdef USE_ESP32 #include "esp_eth.h" +#ifdef USE_ETHERNET_SPI +#include "hal/spi_types.h" +#endif #include "esp_eth_mac.h" #include "esp_eth_mac_esp.h" #include "esp_netif.h" @@ -27,6 +30,20 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #include #elif defined(USE_ETHERNET_W5100) #include +#elif defined(USE_ETHERNET_W6100) +#include +#elif defined(USE_ETHERNET_W6300) +#include +// W6300 uses PIO QSPI, not Arduino SPI. The upstream Wiznet6300 class +// incorrectly returns needsSPI()=true, causing LwipIntfDev::begin() to +// call SPI.begin() which claims GPIOs that PIO QSPI needs. +// This wrapper hides needsSPI() with a version returning false. +class Wiznet6300NoSPI : public Wiznet6300 { + public: + using Wiznet6300::Wiznet6300; + constexpr bool needsSPI() const { return false; } +}; +using Wiznet6300lwIPFixed = LwipIntfDev; #elif defined(USE_ETHERNET_ENC28J60) #include #else @@ -67,6 +84,8 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_DM9051, ETHERNET_TYPE_LAN8670, ETHERNET_TYPE_ENC28J60, + ETHERNET_TYPE_W6100, + ETHERNET_TYPE_W6300, }; struct ManualIP { @@ -135,6 +154,7 @@ class EthernetComponent final : public Component { void set_interrupt_pin(uint8_t interrupt_pin); void set_reset_pin(uint8_t reset_pin); void set_clock_speed(int clock_speed); + void set_interface(spi_host_device_t interface); #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT void set_polling_interval(uint32_t polling_interval); #endif @@ -201,6 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; + spi_host_device_t interface_{SPI3_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif @@ -227,6 +248,8 @@ class EthernetComponent final : public Component { static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls #if defined(USE_ETHERNET_W5100) static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time +#elif defined(USE_ETHERNET_W6300) + static constexpr uint32_t RESET_DELAY_MS = 100; // W6300 needs 100ms after hardware reset #else static constexpr uint32_t RESET_DELAY_MS = 10; #endif @@ -234,6 +257,10 @@ class EthernetComponent final : public Component { Wiznet5500lwIP *eth_{nullptr}; #elif defined(USE_ETHERNET_W5100) Wiznet5100lwIP *eth_{nullptr}; +#elif defined(USE_ETHERNET_W6100) + Wiznet6100lwIP *eth_{nullptr}; +#elif defined(USE_ETHERNET_W6300) + Wiznet6300lwIPFixed *eth_{nullptr}; #elif defined(USE_ETHERNET_ENC28J60) ENC28J60lwIP *eth_{nullptr}; #else diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index a170239e03..d4585bf100 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -158,12 +158,7 @@ void EthernetComponent::setup() { .intr_flags = 0, }; -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) - auto host = SPI2_HOST; -#else - auto host = SPI3_HOST; -#endif + auto host = this->interface_; err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); @@ -458,6 +453,11 @@ void EthernetComponent::dump_config() { " MOSI Pin: %u\n" " CS Pin: %u", this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); + const char *spi_interface = "spi3"; + if (this->interface_ == SPI2_HOST) { + spi_interface = "spi2"; + } + ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface); #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT if (this->polling_interval_ != 0) { ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_); @@ -760,6 +760,7 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } +void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index 9771bc59d5..ef7bd46332 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -18,9 +18,14 @@ static const char *const TAG = "ethernet"; void EthernetComponent::setup() { // Configure SPI pins +#if !defined(USE_ETHERNET_W6300) SPI.setRX(this->miso_pin_); SPI.setTX(this->mosi_pin_); SPI.setSCK(this->clk_pin_); +#endif + // W6300 uses PIO QSPI with hardcoded pins, not Arduino SPI. + // SPI pin config is skipped; Wiznet6300lwIPFixed (needsSPI()=false) + // prevents LwipIntfDev::begin() from calling SPI.begin(). // Toggle reset pin if configured if (this->reset_pin_ >= 0) { @@ -40,6 +45,10 @@ void EthernetComponent::setup() { this->eth_ = new Wiznet5500lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT #elif defined(USE_ETHERNET_W5100) this->eth_ = new Wiznet5100lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT +#elif defined(USE_ETHERNET_W6100) + this->eth_ = new Wiznet6100lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT +#elif defined(USE_ETHERNET_W6300) + this->eth_ = new Wiznet6300lwIPFixed(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT #elif defined(USE_ETHERNET_ENC28J60) this->eth_ = new ENC28J60lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT #endif @@ -183,9 +192,23 @@ void EthernetComponent::dump_config() { type_str = "W5500"; #elif defined(USE_ETHERNET_W5100) type_str = "W5100"; +#elif defined(USE_ETHERNET_W6100) + type_str = "W6100"; +#elif defined(USE_ETHERNET_W6300) + type_str = "W6300"; #elif defined(USE_ETHERNET_ENC28J60) type_str = "ENC28J60"; #endif +#if defined(USE_ETHERNET_W6300) + // W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Type: %s (PIO QSPI)\n" + " Connected: %s\n" + " IRQ Pin: %d\n" + " Reset Pin: %d", + type_str, YESNO(this->is_connected()), this->interrupt_pin_, this->reset_pin_); +#else ESP_LOGCONFIG(TAG, "Ethernet:\n" " Type: %s\n" @@ -198,6 +221,7 @@ void EthernetComponent::dump_config() { " Reset Pin: %d", type_str, YESNO(this->is_connected()), this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_, this->interrupt_pin_, this->reset_pin_); +#endif this->dump_connect_params_(); } 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/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 1538e303f1..0793495e1a 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -202,7 +202,7 @@ async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) cg.add(var.set_volume(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, int) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) cg.add(var.set_duration(template_)) return var @@ -236,7 +236,7 @@ async def ezo_pmp_dose_with_constant_flow_rate_to_code( template_ = await cg.templatable(config[CONF_VOLUME_PER_MINUTE], args, cg.double) cg.add(var.set_volume(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, int) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) cg.add(var.set_duration(template_)) return var @@ -286,7 +286,7 @@ async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, ar paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.double) + template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) cg.add(var.set_address(template_)) return var 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/fan/__init__.py b/esphome/components/fan/__init__.py index df71c6ab3f..ce1e55d36b 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -345,10 +345,10 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if (oscillating := config.get(CONF_OSCILLATING)) is not None: - template_ = await cg.templatable(oscillating, args, bool) + template_ = await cg.templatable(oscillating, args, cg.bool_) cg.add(var.set_oscillating(template_)) if (speed := config.get(CONF_SPEED)) is not None: - template_ = await cg.templatable(speed, args, int) + template_ = await cg.templatable(speed, args, cg.int_) cg.add(var.set_speed(template_)) if (direction := config.get(CONF_DIRECTION)) is not None: template_ = await cg.templatable(direction, args, FanDirection) @@ -370,7 +370,7 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): async def fan_cycle_speed_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_OFF_SPEED_CYCLE], args, bool) + template_ = await cg.templatable(config[CONF_OFF_SPEED_CYCLE], args, cg.bool_) cg.add(var.set_no_off_cycle(template_)) return var diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index dc7a75018c..9301e0cea4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -9,6 +9,22 @@ namespace fan { static const char *const TAG = "fan"; +// Compat: shared empty vector for getter when no preset modes are set. +// Remove in 2026.11.0 when deprecated FanTraits setters are removed +// and getter can return const vector * instead of const vector &. +static const std::vector EMPTY_PRESET_MODES; // NOLINT + +const std::vector &FanTraits::supported_preset_modes() const { + if (this->preset_modes_) { + return *this->preset_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0 (change return to const vector *). + if (!this->compat_preset_modes_.empty()) { + return this->compat_preset_modes_; + } + return EMPTY_PRESET_MODES; +} + // Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN"); @@ -148,6 +164,18 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { + if (preset_mode == nullptr || len == 0) { + return nullptr; + } + if (this->supported_preset_modes_) { + for (const char *mode : *this->supported_preset_modes_) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { + return mode; + } + } + return nullptr; + } + // Fallback for deprecated path: external components may set modes on FanTraits directly return this->get_traits().find_preset_mode(preset_mode, len); } @@ -261,8 +289,6 @@ void Fan::save_state_() { return; } - auto traits = this->get_traits(); - FanRestoreState state{}; state.state = this->state; state.oscillating = this->oscillating; @@ -271,12 +297,25 @@ void Fan::save_state_() { state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { - const auto &preset_modes = traits.supported_preset_modes(); - // Find index of current preset mode (pointer comparison is safe since preset is from traits) - for (size_t i = 0; i < preset_modes.size(); i++) { - if (preset_modes[i] == this->preset_mode_) { - state.preset_mode = i; - break; + if (this->supported_preset_modes_) { + // New path: search Fan-owned vector directly + for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { + if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } + } + } else { + // Compat: fall back to traits for deprecated path. Remove in 2026.11.0. + // Pointer comparison works because preset_mode_ and the compat vector both + // hold pointers to string literals in .rodata (stable addresses). + auto traits = this->get_traits(); + const auto &preset_modes = traits.supported_preset_modes(); + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } } } } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index e7b3681e32..d5763edf2f 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -130,6 +130,14 @@ class Fan : public EntityBase { virtual FanTraits get_traits() = 0; + /// Set the supported preset modes (stored on Fan, referenced by FanTraits via pointer). + void set_supported_preset_modes(std::initializer_list preset_modes) { + this->ensure_preset_modes_().assign(preset_modes.begin(), preset_modes.end()); + } + void set_supported_preset_modes(const std::vector &preset_modes) { + this->ensure_preset_modes_() = preset_modes; + } + /// Set the restore mode of this fan. void set_restore_mode(FanRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -167,11 +175,27 @@ class Fan : public EntityBase { const char *find_preset_mode_(const char *preset_mode); const char *find_preset_mode_(const char *preset_mode, size_t len); + /// Wire the Fan-owned preset modes pointer into the given traits object. + void wire_preset_modes_(FanTraits &traits) { + if (this->supported_preset_modes_) { + traits.set_supported_preset_modes_(this->supported_preset_modes_); + } + } + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; private: + /// Lazy-allocate preset modes vector (never freed — entity lives forever). + std::vector &ensure_preset_modes_() { + if (!this->supported_preset_modes_) { + this->supported_preset_modes_ = new std::vector(); // NOLINT + } + return *this->supported_preset_modes_; + } + + std::vector *supported_preset_modes_{nullptr}; const char *preset_mode_{nullptr}; }; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index c0c5f34c50..a2b2633af1 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -3,12 +3,17 @@ #include #include #include +#include "esphome/core/helpers.h" namespace esphome { namespace fan { +class Fan; // Forward declaration + class FanTraits { + friend class Fan; // Allow Fan to access protected pointer setter + public: FanTraits() = default; FanTraits(bool oscillation, bool speed, bool direction, int speed_count) @@ -30,42 +35,64 @@ class FanTraits { bool supports_direction() const { return this->direction_; } /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } - /// Return the preset modes supported by the fan. - const std::vector &supported_preset_modes() const { return this->preset_modes_; } - /// Set the preset modes supported by the fan (from initializer list). + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &supported_preset_modes() const; + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - this->preset_modes_ = preset_modes; + // Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector). + this->compat_preset_modes_ = preset_modes; + } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_preset_modes(const std::vector &preset_modes) { + this->compat_preset_modes_ = preset_modes; } - /// Set the preset modes supported by the fan (from vector). - void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_preset_modes(const std::vector &preset_modes) = delete; void set_supported_preset_modes(std::initializer_list preset_modes) = delete; /// Return if preset modes are supported - bool supports_preset_modes() const { return !this->preset_modes_.empty(); } + bool supports_preset_modes() const { + // Same precedence as supported_preset_modes() getter + if (this->preset_modes_) { + return !this->preset_modes_->empty(); + } + return !this->compat_preset_modes_.empty(); + } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); } const char *find_preset_mode(const char *preset_mode, size_t len) const { - if (preset_mode == nullptr || len == 0) + if (preset_mode == nullptr || len == 0) { return nullptr; - for (const char *mode : this->preset_modes_) { + } + // Check pointer-based storage (new path) then compat owned vector (deprecated path) + const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->compat_preset_modes_; + for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { - return mode; // Return pointer from traits + return mode; } } return nullptr; } protected: + /// Set the preset modes pointer (only Fan::wire_preset_modes_() should call this). + void set_supported_preset_modes_(const std::vector *preset_modes) { + this->preset_modes_ = preset_modes; + } + bool oscillation_{false}; bool speed_{false}; bool direction_{false}; int speed_count_{}; - std::vector preset_modes_{}; + const std::vector *preset_modes_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector). + // Remove in 2026.11.0. + std::vector compat_preset_modes_; }; } // namespace fan 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/font/__init__.py b/esphome/components/font/__init__.py index c8813bf1bc..a1339a4bc1 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -238,7 +238,7 @@ def validate_font_config(config): return config -FONT_EXTENSIONS = (".ttf", ".woff", ".otf", "bdf", ".pcf") +FONT_EXTENSIONS = (".ttf", ".woff", ".otf", ".bdf", ".pcf") def validate_truetype_file(value): diff --git a/esphome/components/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index ec26447ccb..e4de7721c6 100644 --- a/esphome/components/gcja5/sensor.py +++ b/esphome/components/gcja5/sensor.py @@ -24,73 +24,77 @@ DEPENDENCIES = ["uart"] gcja5_ns = cg.esphome_ns.namespace("gcja5") -GCJA5Component = gcja5_ns.class_("GCJA5Component", cg.PollingComponent, uart.UARTDevice) +GCJA5Component = gcja5_ns.class_("GCJA5Component", cg.Component, uart.UARTDevice) CONF_PMC_0_3 = "pmc_0_3" CONF_PMC_5_0 = "pmc_5_0" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(GCJA5Component), - cv.Optional(CONF_PM_1_0): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_CHEMICAL_WEAPON, - accuracy_decimals=2, - device_class=DEVICE_CLASS_PM1, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PM_2_5): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_CHEMICAL_WEAPON, - accuracy_decimals=2, - device_class=DEVICE_CLASS_PM25, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PM_10_0): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_CHEMICAL_WEAPON, - accuracy_decimals=2, - device_class=DEVICE_CLASS_PM10, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PMC_0_3): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_COUNTER, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PMC_0_5): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_COUNTER, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PMC_1_0): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_COUNTER, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PMC_2_5): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_COUNTER, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PMC_5_0): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_COUNTER, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_PMC_10_0): sensor.sensor_schema( - unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, - icon=ICON_COUNTER, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - } -).extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(GCJA5Component), + cv.Optional(CONF_PM_1_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM1, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_2_5): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_10_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM10, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PMC_0_3): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_COUNTER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PMC_0_5): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_COUNTER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PMC_1_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_COUNTER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PMC_2_5): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_COUNTER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PMC_5_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_COUNTER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PMC_10_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_COUNTER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "gcja5", baud_rate=9600, require_rx=True, parity="EVEN" ) diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 8b381564b2..0ee718cd20 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -6,9 +6,15 @@ namespace esphome { namespace gdk101 { static const char *const TAG = "gdk101"; -static const uint8_t NUMBER_OF_READ_RETRIES = 5; +static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5; +static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 30; +static constexpr uint32_t RESET_INTERVAL_ID = 0; +static constexpr uint32_t RESET_INTERVAL_MS = 1000; void GDK101Component::update() { + if (!this->reset_complete_) + return; + uint8_t data[2]; if (!this->read_dose_1m_(data)) { this->status_set_warning(LOG_STR("Failed to read dose 1m")); @@ -33,26 +39,45 @@ void GDK101Component::update() { } void GDK101Component::setup() { - uint8_t data[2]; - // first, reset the sensor + if (!this->try_reset_()) { + // Sensor MCU boots slowly after power cycle — retry on a short interval + this->reset_retries_remaining_ = NUMBER_OF_RESET_RETRIES; + this->set_interval(RESET_INTERVAL_ID, RESET_INTERVAL_MS, [this]() { + if (this->try_reset_()) { + if (this->reset_complete_) { + this->update(); + } + return; + } + if (--this->reset_retries_remaining_ == 0) { + this->cancel_interval(RESET_INTERVAL_ID); + this->mark_failed(LOG_STR("Reset failed after retries")); + } + }); + } +} + +/// Attempt to reset the sensor and read firmware version. Returns true on success or hard failure. +bool GDK101Component::try_reset_() { + uint8_t data[2] = {0}; if (!this->reset_sensor_(data)) { - this->status_set_error(LOG_STR("Reset failed!")); - this->mark_failed(); - return; + this->status_set_warning(LOG_STR("Sensor not answering reset, will retry")); + return false; } - // sensor should acknowledge success of the reset procedure if (data[0] != 1) { - this->status_set_error(LOG_STR("Reset not acknowledged!")); - this->mark_failed(); - return; + this->status_set_warning(LOG_STR("Reset not acknowledged, will retry")); + return false; } delay(10); - // read firmware version if (!this->read_fw_version_(data)) { - this->status_set_error(LOG_STR("Failed to read firmware version")); - this->mark_failed(); - return; + this->cancel_interval(RESET_INTERVAL_ID); + this->mark_failed(LOG_STR("Failed to read firmware version")); + return true; } + this->reset_complete_ = true; + this->status_clear_warning(); + this->cancel_interval(RESET_INTERVAL_ID); + return true; } void GDK101Component::dump_config() { @@ -92,12 +117,7 @@ bool GDK101Component::reset_sensor_(uint8_t *data) { // After sending reset command it looks that sensor start performing reset and is unresponsible during read // after a while we can send another reset command and read "0x01" as confirmation // Documentation not going in to such details unfortunately - if (!this->read_bytes_with_retry_(GDK101_REG_RESET, data, 2)) { - ESP_LOGE(TAG, "Updating GDK101 failed!"); - return false; - } - - return true; + return this->read_bytes_with_retry_(GDK101_REG_RESET, data, 2); } bool GDK101Component::read_dose_1m_(uint8_t *data) { diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index abe417e0f9..abe3fd60d8 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -44,12 +44,15 @@ class GDK101Component : public PollingComponent, public i2c::I2CDevice { protected: bool read_bytes_with_retry_(uint8_t a_register, uint8_t *data, uint8_t len); + bool try_reset_(); bool reset_sensor_(uint8_t *data); bool read_dose_1m_(uint8_t *data); bool read_dose_10m_(uint8_t *data); bool read_status_(uint8_t *data); bool read_fw_version_(uint8_t *data); bool read_measurement_duration_(uint8_t *data); + bool reset_complete_{false}; + uint8_t reset_retries_remaining_{0}; }; } // namespace gdk101 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/globals/__init__.py b/esphome/components/globals/__init__.py index fe83b1ea7c..ec6730a41c 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -109,7 +109,7 @@ async def globals_set_to_code(config, action_id, template_arg, args): template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) templ = await cg.templatable( - config[CONF_VALUE], args, None, to_exp=cg.RawExpression + config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True ) cg.add(var.set_value(templ)) return var diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index eeff98cb6e..ddb9e63686 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -28,7 +28,10 @@ namespace esphome::gpio_expander { template 256), uint16_t, uint8_t>::type> class CachedGpioExpander { public: - /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. + /// @brief Read the state of the given pin. + /// By default, each read invalidates the pin's cache entry so the next read + /// of the same pin triggers a fresh hardware read. When invalidate_on_read + /// is disabled, the cache stays valid until explicitly cleared via reset_pin_cache_(). /// @param pin Pin number to read /// @return Pin state bool digital_read(P pin) { @@ -36,14 +39,17 @@ class CachedGpioExpander { const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid if (this->read_cache_valid_[bank] & pin_mask) { - // Invalidate pin - this->read_cache_valid_[bank] &= ~pin_mask; + if (this->invalidate_on_read_) { + // Invalidate pin so next read triggers hardware read + this->read_cache_valid_[bank] &= ~pin_mask; + } } else { // Read whole bank from hardware if (!this->digital_read_hw(pin)) return false; // Mark bank cache as valid except the pin that is being returned now - this->read_cache_valid_[bank] = std::numeric_limits::max() & ~pin_mask; + // (when not invalidating on read, mark all pins including this one as valid) + this->read_cache_valid_[bank] = std::numeric_limits::max() & ~(this->invalidate_on_read_ ? pin_mask : 0); } return this->digital_read_cache(pin); } @@ -71,12 +77,18 @@ class CachedGpioExpander { /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } + /// @brief Control whether digital_read() invalidates the pin's cache entry after reading. + /// When enabled (default), each read self-invalidates so the next read triggers a hardware read. + /// When disabled, cache stays valid until reset_pin_cache_() is explicitly called. + void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; } + static constexpr uint16_t BITS_PER_BYTE = 8; static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; static constexpr size_t BANKS = N / BANK_SIZE; static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); T read_cache_valid_[BANKS]{0}; + bool invalidate_on_read_{true}; }; } // namespace esphome::gpio_expander 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..ae64c049f5 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -78,13 +78,11 @@ async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, int) + template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) 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 @@ -103,7 +101,7 @@ async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, int) + template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) cg.add(var.set_channel(template_channel)) return var @@ -123,7 +121,7 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, int) + template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) cg.add(var.set_channel(template_channel)) return var @@ -177,6 +175,6 @@ async def grove_tb6612fng_change_address_to_code(config, action_id, template_arg var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_ADDRESS], args, int) + template_channel = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) cg.add(var.set_address(template_channel)) 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/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 19f3adfd0e..7458b88b72 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -12,7 +12,9 @@ from esphome.const import ( CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, @@ -53,6 +55,7 @@ PHASE_SENSORS = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, @@ -72,6 +75,7 @@ PV_SENSORS = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, @@ -118,6 +122,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_ACTIVE_POWER): sensor.sensor_schema( @@ -147,6 +152,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_INVERTER_MODULE_TEMP): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), } 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/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index 532315a1d1..f0683e1d9c 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -13,7 +13,9 @@ from esphome.const import ( CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, @@ -64,6 +66,7 @@ PHASE_SENSORS = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, @@ -77,6 +80,7 @@ PV_SENSORS = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, @@ -123,6 +127,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_ACTIVE_POWER): sensor.sensor_schema( @@ -134,6 +139,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_REACTIVE_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, accuracy_decimals=2, + device_class=DEVICE_CLASS_REACTIVE_POWER, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_ENERGY_PRODUCTION_DAY): sensor.sensor_schema( @@ -171,6 +177,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_INVERTER_BUS_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_INSULATION_OF_PV_N_TO_GROUND): sensor.sensor_schema( @@ -181,21 +188,25 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_GFCI_VALUE): sensor.sensor_schema( unit_of_measurement=UNIT_MILLIAMPERE, accuracy_decimals=0, + device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DCI_OF_R): sensor.sensor_schema( unit_of_measurement=UNIT_MILLIAMPERE, accuracy_decimals=0, + device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DCI_OF_S): sensor.sensor_schema( unit_of_measurement=UNIT_MILLIAMPERE, accuracy_decimals=0, + device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DCI_OF_T): sensor.sensor_schema( unit_of_measurement=UNIT_MILLIAMPERE, accuracy_decimals=0, + device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), } diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 89c162eebf..d548128b99 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -30,7 +30,6 @@ fan::FanCall HBridgeFan::brake() { void HBridgeFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, true, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index ec1e8ada0e..997f66ae48 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -20,11 +20,14 @@ class HBridgeFan : public Component, public fan::Fan { void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } - void set_preset_modes(std::initializer_list presets) { preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } void setup() override; void dump_config() override; - fan::FanTraits get_traits() override { return this->traits_; } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } fan::FanCall brake(); @@ -36,7 +39,6 @@ class HBridgeFan : public Component, public fan::Fan { int speed_count_{}; DecayMode decay_mode_{DECAY_MODE_SLOW}; fan::FanTraits traits_; - std::vector preset_modes_{}; void control(const fan::FanCall &call) override; void write_state_(); 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/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index d0fd697d8f..22f292e47e 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -73,13 +73,13 @@ void HLW8012Component::update() { // Only read cf1 after one cycle. Apparently it's quite unstable after being changed. if (this->current_mode_) { float current = cf1_hz * this->current_multiplier_; - ESP_LOGD(TAG, "Got power=%.1fW, current=%.1fA", power, current); + ESP_LOGV(TAG, "Got power=%.1fW, current=%.1fA", power, current); if (this->current_sensor_ != nullptr) { this->current_sensor_->publish_state(current); } } else { float voltage = cf1_hz * this->voltage_multiplier_; - ESP_LOGD(TAG, "Got power=%.1fW, voltage=%.1fV", power, voltage); + ESP_LOGV(TAG, "Got power=%.1fW, voltage=%.1fV", power, voltage); if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(voltage); } diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index 96800e46f4..846c9a398b 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -27,42 +27,46 @@ DEPENDENCIES = ["uart"] hlw8032_ns = cg.esphome_ns.namespace("hlw8032") HLW8032Component = hlw8032_ns.class_("HLW8032Component", cg.Component, uart.UARTDevice) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(HLW8032Component), - cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT_AMPS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_APPARENT_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( - accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CURRENT_RESISTOR, default=0.001): cv.resistance, - cv.Optional(CONF_VOLTAGE_DIVIDER, default=1.720): cv.positive_float, - } -).extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HLW8032Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT_RESISTOR, default=0.001): cv.resistance, + cv.Optional(CONF_VOLTAGE_DIVIDER, default=1.720): cv.positive_float, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "hlw8032", baud_rate=4800, require_rx=True, data_bits=8, parity="EVEN" diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index 96d0313008..cf3c594f36 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -87,6 +87,7 @@ heading_schema = sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, icon=ICON_SCREEN_ROTATION, accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, ) CONFIG_SCHEMA = ( diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index a662e842ee..0ade4274fe 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -5,11 +5,17 @@ #include "esphome/core/helpers.h" #include "preferences.h" +#include #include #include #include #include +namespace { +volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +void signal_handler(int signal) { s_signal_received = signal; } +} // namespace + namespace esphome { void HOT yield() { ::sched_yield(); } @@ -72,11 +78,17 @@ uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } void setup(); void loop(); int main() { + // Install signal handlers for graceful shutdown (flushes preferences to disk) + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); + esphome::host::setup_preferences(); setup(); - while (true) { + while (s_signal_received == 0) { loop(); } + esphome::App.run_safe_shutdown_hooks(); + return 0; } #endif // USE_HOST diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 416432cfc4..90879c459e 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -302,11 +302,13 @@ async def http_request_action_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) cg.add(var.set_url(template_)) - cg.add(var.set_method(config[CONF_METHOD])) + template_ = await cg.templatable(config[CONF_METHOD], args, cg.const_char_ptr) + cg.add(var.set_method(template_)) capture_response = config[CONF_CAPTURE_RESPONSE] if capture_response: - cg.add(var.set_capture_response(capture_response)) + template_ = await cg.templatable(capture_response, args, cg.bool_) + cg.add(var.set_capture_response(template_)) cg.add_define("USE_HTTP_REQUEST_RESPONSE") cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 73dbda8694..ae73983bab 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -457,7 +457,7 @@ template class HttpRequestSendAction : public Action { #endif void init_request_headers(size_t count) { this->request_headers_.init(count); } - void add_request_header(const char *key, TemplatableValue value) { + void add_request_header(const char *key, TemplatableFn value) { this->request_headers_.push_back({key, value}); } @@ -560,7 +560,7 @@ template class HttpRequestSendAction : public Action { } } HttpRequestComponent *parent_; - FixedVector>> request_headers_{}; + FixedVector>> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index ed4fb5968a..8808dc70f5 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -98,7 +98,7 @@ async def to_code(config): async def set_heater_level_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - level_ = await cg.templatable(config[CONF_LEVEL], args, int) + level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) cg.add(var.set_level(level_)) return var @@ -118,6 +118,6 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): async def set_heater_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - status_ = await cg.templatable(config[CONF_STATUS], args, bool) + status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) cg.add(var.set_status(status_)) return var diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index f270b72e24..fdb606182f 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_TEMPERATURE, DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, + DEVICE_CLASS_TEMPERATURE, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -117,6 +118,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=0, icon=ICON_THERMOMETER, + device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DISABLE_LED): cv.boolean, diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index e69de29bb2..84888bbabc 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -0,0 +1,4 @@ +DEPRECATED_COMPONENT = """ +The 'ili9xxx' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index bfb2300f4f..1f20b21a0e 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -210,8 +210,8 @@ def final_validate(config): ): LOGGER.info("Consider enabling PSRAM if available for the display buffer") - return spi.final_validate_device_schema( - "ili9xxx", require_miso=False, require_mosi=True + spi.final_validate_device_schema("ili9xxx", require_miso=False, require_mosi=True)( + config ) @@ -219,6 +219,9 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config): + LOGGER.warning( + "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 6fb0e46d93..4a5fcc385e 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -12,7 +12,7 @@ from PIL import Image, UnidentifiedImageError from esphome import core, external_files import esphome.codegen as cg -from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv from esphome.const import ( CONF_DEFAULTS, @@ -53,7 +53,6 @@ CONF_CHROMA_KEY = "chroma_key" CONF_ALPHA_CHANNEL = "alpha_channel" CONF_INVERT_ALPHA = "invert_alpha" CONF_IMAGES = "images" -KEY_METADATA = "metadata" TRANSPARENCY_TYPES = ( CONF_OPAQUE, diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index d0aae4201e..8d784df672 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -133,6 +133,6 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/internal_temperature/internal_temperature_ln882x.cpp b/esphome/components/internal_temperature/internal_temperature_ln882x.cpp new file mode 100644 index 0000000000..621fbed030 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_ln882x.cpp @@ -0,0 +1,24 @@ +#ifdef USE_LN882X + +#include "internal_temperature.h" + +extern "C" { +uint16_t hal_adc_get_data(uint32_t adc_base, uint32_t ch); +} + +namespace esphome::internal_temperature { + +void InternalTemperatureSensor::update() { + static constexpr uint32_t ADC_BASE = 0x40000800U; + static constexpr uint32_t ADC_CH0 = 1U; + static constexpr uint16_t ADC_MASK = 0xFFF; + static constexpr float ADC_TEMP_SCALE = 2.54f; + static constexpr float ADC_TEMP_OFFSET = 278.15f; + uint16_t raw = hal_adc_get_data(ADC_BASE, ADC_CH0); + float temperature = (raw & ADC_MASK) / ADC_TEMP_SCALE - ADC_TEMP_OFFSET; + this->publish_state(temperature); +} + +} // namespace esphome::internal_temperature + +#endif // USE_LN882X diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 6d79e08675..02730b6862 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, PLATFORM_BK72XX, PLATFORM_ESP32, + PLATFORM_LN882X, PLATFORM_NRF52, PLATFORM_RP2040, STATE_CLASS_MEASUREMENT, @@ -30,7 +31,15 @@ CONFIG_SCHEMA = cv.All( state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.polling_component_schema("60s")), - cv.only_on([PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_NRF52]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_RP2040, + PLATFORM_BK72XX, + PLATFORM_NRF52, + PLATFORM_LN882X, + ] + ), ) @@ -53,6 +62,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "internal_temperature_bk72xx.cpp": { PlatformFramework.BK72XX_ARDUINO, }, + "internal_temperature_ln882x.cpp": { + PlatformFramework.LN882X_ARDUINO, + }, "internal_temperature_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 6c60a04d20..edcd23f922 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -140,8 +140,11 @@ SerializationBuffer<> JsonBuilder::serialize() { heap_size *= 2; } // Payload exceeds 5120 bytes - return truncated result - ESP_LOGW(TAG, "JSON payload too large, truncated to %zu bytes", size); - result.set_size_(size); + // heap_size was doubled after the last iteration, so the actual allocated + // buffer capacity is heap_size/2. Clamp to avoid writing past the buffer. + size_t max_content = heap_size / 2 - 1; + ESP_LOGW(TAG, "JSON payload too large, truncated to %zu bytes", max_content); + result.set_size_(max_content); return result; } diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index fb37ac2c8d..134ac245bf 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLUME, + DEVICE_CLASS_VOLUME_FLOW_RATE, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, UNIT_CELSIUS, @@ -75,7 +76,7 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_FLOW): sensor.sensor_schema( accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLUME, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=UNIT_LITRE_PER_HOUR, ), diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index eb08a522e5..75ae703638 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(lc709203f), - cv.Optional(CONF_SIZE, default="500"): cv.int_range(100, 3000), + cv.Optional(CONF_SIZE, default=500): cv.int_range(100, 3000), cv.Optional(CONF_VOLTAGE, default="3.7"): cv.enum( BATTERY_VOLTAGE_OPTIONS, upper=True ), 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/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index 6dde35753a..97acdabd7b 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -5,6 +5,7 @@ from esphome.const import ( CONF_ID, CONF_MOVING_DISTANCE, DEVICE_CLASS_DISTANCE, + STATE_CLASS_MEASUREMENT, UNIT_CENTIMETER, ) @@ -20,7 +21,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(LD2420Sensor), cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( - device_class=DEVICE_CLASS_DISTANCE, unit_of_measurement=UNIT_CENTIMETER + device_class=DEVICE_CLASS_DISTANCE, + unit_of_measurement=UNIT_CENTIMETER, + state_class=STATE_CLASS_MEASUREMENT, ), } ), 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/ledc/output.py b/esphome/components/ledc/output.py index 62ff5ad30a..95df1fba23 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -82,6 +82,6 @@ async def to_code(config): async def ledc_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8f99124604..656eee6d7b 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE +from esphome.core.config import BOARD_MAX_LENGTH from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -266,7 +267,9 @@ CONFIG_SCHEMA = cv.All(_notify_old_style) BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LTComponent), - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FAMILY): cv.one_of(*FAMILIES, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA, }, 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/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index e812b6a8f2..6f71530aaf 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -43,6 +43,6 @@ async def to_code(config): async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2854bc62d9..f6a2ca52d4 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -45,20 +45,34 @@ template class LightControlAction : public Action { void play(const Ts &...x) override { auto call = this->parent_->make_call(); - call.set_color_mode(this->color_mode_.optional_value(x...)); - call.set_state(this->state_.optional_value(x...)); - call.set_brightness(this->brightness_.optional_value(x...)); - call.set_color_brightness(this->color_brightness_.optional_value(x...)); - call.set_red(this->red_.optional_value(x...)); - call.set_green(this->green_.optional_value(x...)); - call.set_blue(this->blue_.optional_value(x...)); - call.set_white(this->white_.optional_value(x...)); - call.set_color_temperature(this->color_temperature_.optional_value(x...)); - call.set_cold_white(this->cold_white_.optional_value(x...)); - call.set_warm_white(this->warm_white_.optional_value(x...)); - call.set_effect(this->effect_.optional_value(x...)); - call.set_flash_length(this->flash_length_.optional_value(x...)); - call.set_transition_length(this->transition_length_.optional_value(x...)); + if (this->color_mode_.has_value()) + call.set_color_mode(this->color_mode_.value(x...)); + if (this->state_.has_value()) + call.set_state(this->state_.value(x...)); + if (this->transition_length_.has_value()) + call.set_transition_length(this->transition_length_.value(x...)); + if (this->flash_length_.has_value()) + call.set_flash_length(this->flash_length_.value(x...)); + if (this->brightness_.has_value()) + call.set_brightness(this->brightness_.value(x...)); + if (this->color_brightness_.has_value()) + call.set_color_brightness(this->color_brightness_.value(x...)); + if (this->red_.has_value()) + call.set_red(this->red_.value(x...)); + if (this->green_.has_value()) + call.set_green(this->green_.value(x...)); + if (this->blue_.has_value()) + call.set_blue(this->blue_.value(x...)); + if (this->white_.has_value()) + call.set_white(this->white_.value(x...)); + if (this->color_temperature_.has_value()) + call.set_color_temperature(this->color_temperature_.value(x...)); + if (this->cold_white_.has_value()) + call.set_cold_white(this->cold_white_.value(x...)); + if (this->warm_white_.has_value()) + call.set_warm_white(this->warm_white_.value(x...)); + if (this->effect_.has_value()) + call.set_effect(this->effect_.value(x...)); call.perform(); } diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 16e7d72f6b..46d37239e5 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -179,47 +179,28 @@ def _resolve_effect_index(config: ConfigType) -> int: async def light_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_COLOR_MODE in config: - template_ = await cg.templatable(config[CONF_COLOR_MODE], args, ColorMode) - cg.add(var.set_color_mode(template_)) - if CONF_STATE in config: - template_ = await cg.templatable(config[CONF_STATE], args, bool) - cg.add(var.set_state(template_)) - if CONF_TRANSITION_LENGTH in config: - template_ = await cg.templatable( - config[CONF_TRANSITION_LENGTH], args, cg.uint32 - ) - cg.add(var.set_transition_length(template_)) - if CONF_FLASH_LENGTH in config: - template_ = await cg.templatable(config[CONF_FLASH_LENGTH], args, cg.uint32) - cg.add(var.set_flash_length(template_)) - if CONF_BRIGHTNESS in config: - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, float) - cg.add(var.set_brightness(template_)) - if CONF_COLOR_BRIGHTNESS in config: - template_ = await cg.templatable(config[CONF_COLOR_BRIGHTNESS], args, float) - cg.add(var.set_color_brightness(template_)) - if CONF_RED in config: - template_ = await cg.templatable(config[CONF_RED], args, float) - cg.add(var.set_red(template_)) - if CONF_GREEN in config: - template_ = await cg.templatable(config[CONF_GREEN], args, float) - cg.add(var.set_green(template_)) - if CONF_BLUE in config: - template_ = await cg.templatable(config[CONF_BLUE], args, float) - cg.add(var.set_blue(template_)) - if CONF_WHITE in config: - template_ = await cg.templatable(config[CONF_WHITE], args, float) - cg.add(var.set_white(template_)) - if CONF_COLOR_TEMPERATURE in config: - template_ = await cg.templatable(config[CONF_COLOR_TEMPERATURE], args, float) - cg.add(var.set_color_temperature(template_)) - if CONF_COLD_WHITE in config: - template_ = await cg.templatable(config[CONF_COLD_WHITE], args, float) - cg.add(var.set_cold_white(template_)) - if CONF_WARM_WHITE in config: - template_ = await cg.templatable(config[CONF_WARM_WHITE], args, float) - cg.add(var.set_warm_white(template_)) + + # (config_key, setter_name, c++ type) + FIELDS = ( + (CONF_COLOR_MODE, "set_color_mode", ColorMode), + (CONF_STATE, "set_state", cg.bool_), + (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), + (CONF_BRIGHTNESS, "set_brightness", cg.float_), + (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + (CONF_RED, "set_red", cg.float_), + (CONF_GREEN, "set_green", cg.float_), + (CONF_BLUE, "set_blue", cg.float_), + (CONF_WHITE, "set_white", cg.float_), + (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + (CONF_COLD_WHITE, "set_cold_white", cg.float_), + (CONF_WARM_WHITE, "set_warm_white", cg.float_), + ) + for conf_key, setter, type_ in FIELDS: + if conf_key in config: + template_ = await cg.templatable(config[conf_key], args, type_) + cg.add(getattr(var, setter)(template_)) + if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): # Lambda returns a string — wrap in a C++ lambda that resolves @@ -242,8 +223,10 @@ async def light_control_to_code(config, action_id, template_arg, args): cg.add(var.set_effect(wrapper)) else: # Static string — resolve effect name to index at codegen time - effect_index = _resolve_effect_index(config) - cg.add(var.set_effect(effect_index)) + template_ = await cg.templatable( + _resolve_effect_index(config), args, cg.uint32 + ) + cg.add(var.set_effect(template_)) return var @@ -279,7 +262,7 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( async def light_dim_relative_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, float) + templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, cg.float_) cg.add(var.set_relative_brightness(templ)) if CONF_TRANSITION_LENGTH in config: templ = await cg.templatable(config[CONF_TRANSITION_LENGTH], args, cg.uint32) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7c936b51b7..a749cd7305 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -198,13 +198,13 @@ LightColorValues LightCall::validate_() { // Ensure there is always a color mode set if (!this->has_color_mode()) { - this->color_mode_ = this->compute_color_mode_(); + this->color_mode_ = this->compute_color_mode_(traits); this->set_flag_(FLAG_HAS_COLOR_MODE); } auto color_mode = this->color_mode_; // Transform calls that use non-native parameters for the current mode. - this->transform_parameters_(); + this->transform_parameters_(traits); // Business logic adjustments before validation // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. @@ -366,9 +366,7 @@ LightColorValues LightCall::validate_() { return v; } -void LightCall::transform_parameters_() { - auto traits = this->parent_->get_traits(); - +void LightCall::transform_parameters_(const LightTraits &traits) { // Allow CWWW modes to be set with a white value and/or color temperature. // This is used in three cases in HA: // - CW/WW lights, which set the "brightness" and "color_temperature" @@ -407,8 +405,8 @@ void LightCall::transform_parameters_() { } } } -ColorMode LightCall::compute_color_mode_() { - auto supported_modes = this->parent_->get_traits().get_supported_color_modes(); +ColorMode LightCall::compute_color_mode_(const LightTraits &traits) { + auto supported_modes = traits.get_supported_color_modes(); int supported_count = supported_modes.size(); // Some lights don't support any color modes (e.g. monochromatic light), leave it at unknown. diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 0eb1785239..88d29bd349 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -10,6 +10,7 @@ struct LogString; namespace light { class LightState; +class LightTraits; /** This class represents a requested change in a light state. * @@ -188,11 +189,11 @@ class LightCall { LightColorValues validate_(); //// Compute the color mode that should be used for this call. - ColorMode compute_color_mode_(); + ColorMode compute_color_mode_(const LightTraits &traits); /// Get potential color modes bitmask for this light call. color_mode_bitmask_t get_suitable_color_modes_mask_(); /// Some color modes also can be set using non-native parameters, transform those calls. - void transform_parameters_(); + void transform_parameters_(const LightTraits &traits); // Bitfield flags - each flag indicates whether a corresponding value has been set. enum FieldFlags : uint16_t { diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index a2c2dbca46..fa286a3941 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -104,9 +104,10 @@ class LightColorValues { this->green_ = 1.0f; this->blue_ = 1.0f; } else { - this->red_ /= max_value; - this->green_ /= max_value; - this->blue_ /= max_value; + float inv = 1.0f / max_value; + this->red_ *= inv; + this->green_ *= inv; + this->blue_ *= inv; } } } diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index acbbbb4de9..76eabc2b71 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")) @@ -61,15 +61,13 @@ async def send_raw_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - repeats = await cg.templatable(config[CONF_REPEAT], args, int) - inverted = await cg.templatable(config[CONF_INVERTED], args, bool) - pulse_length = await cg.templatable(config[CONF_PULSE_LENGTH], args, int) - code = config[CONF_CODE] - - cg.add(var.set_repeats(repeats)) - cg.add(var.set_inverted(inverted)) - cg.add(var.set_pulse_length(pulse_length)) - cg.add(var.set_data(code)) + template_ = await cg.templatable(config[CONF_REPEAT], args, cg.int_) + cg.add(var.set_repeat(template_)) + template_ = await cg.templatable(config[CONF_INVERTED], args, cg.int_) + cg.add(var.set_inverted(template_)) + template_ = await cg.templatable(config[CONF_PULSE_LENGTH], args, cg.int_) + cg.add(var.set_pulse_length(template_)) + cg.add(var.set_code(config[CONF_CODE])) return var diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index ee4e91e9d1..6210e6b5d4 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -45,11 +45,7 @@ template class SendRawAction : public Action { TEMPLATABLE_VALUE(int, inverted); TEMPLATABLE_VALUE(int, pulse_length); TEMPLATABLE_VALUE(std::vector, code); - - void set_repeats(const int &data) { repeat_ = data; } - void set_inverted(const int &data) { inverted_ = data; } - void set_pulse_length(const int &data) { pulse_length_ = data; } - void set_data(const std::vector &data) { code_ = data; } + void set_code(std::initializer_list data) { this->code_ = std::vector(data); } void play(const Ts &...x) { int repeats = this->repeat_.value(x...); 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/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index b9507e707a..5797b03ba7 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -1,5 +1,9 @@ #ifdef USE_ESP8266 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_ESP8266_CRASH_HANDLER +#include "esphome/components/esp8266/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,6 +30,9 @@ void Logger::pre_setup() { global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP8266_CRASH_HANDLER + esp8266::crash_handler_log(); +#endif } const LogString *Logger::get_uart_selection_() { diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 579adb9051..37fceaf984 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, + STATE_CLASS_MEASUREMENT, UNIT_LUX, ) @@ -57,24 +58,28 @@ CONFIG_SCHEMA = cv.All( icon=ICON_BRIGHTNESS_5, accuracy_decimals=1, device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AMBIENT_LIGHT): sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=1, device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_UV_INDEX): sensor.sensor_schema( unit_of_measurement=UNIT_UVI, icon=ICON_BRIGHTNESS_5, accuracy_decimals=5, device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_UV): sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=1, device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_GAIN, default="X18"): cv.Any( cv.enum(GAIN_OPTIONS), 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/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 3b4f150699..f6f6204f4c 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -2,14 +2,14 @@ import importlib from pathlib import Path import pkgutil -from esphome.automation import build_automation, validate_automation +from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import ( CONF_BYTE_ORDER, CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, ) -from esphome.components.display import Display +from esphome.components.display import Display, get_display_metadata, validate_rotation from esphome.components.esp32 import ( VARIANT_ESP32P4, add_idf_component, @@ -34,9 +34,9 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_LOG_LEVEL, - CONF_ON_BOOT, CONF_ON_IDLE, CONF_PAGES, + CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, ) @@ -58,7 +58,7 @@ from .encoders import ( from .gradient import GRADIENT_SCHEMA, gradients_to_code from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool, lv_images_used -from .lvcode import LvContext, LvglComponent, lvgl_static +from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, @@ -70,13 +70,15 @@ from .schemas import ( ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code -from .trigger import add_on_boot_triggers, generate_align_tos, generate_triggers +from .trigger import generate_align_tos, generate_triggers from .types import ( IdleTrigger, PlainTrigger, + RotationType, lv_font_t, lv_group_t, lv_lambda_t, + lv_obj_t_ptr, lv_style_t, lvgl_ns, ) @@ -192,6 +194,11 @@ def final_validation(config_list): raise cv.Invalid( "Using lambda: or pages: in display config is not compatible with LVGL" ) + # treating 0 as false is intended here. + if display.get(CONF_ROTATION): + raise cv.Invalid( + "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" + ) if display.get(CONF_AUTO_CLEAR_ENABLED) is True: raise cv.Invalid( "Using auto_clear_enabled: true in display config not compatible with LVGL" @@ -254,6 +261,7 @@ async def to_code(configs): df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") + df.add_define("LV_DEF_REFR_PERIOD", "16") cg.add_define("USE_LVGL") # suppress default enabling of extra widgets # cg.add_define("LV_KCONFIG_PRESENT") @@ -322,6 +330,21 @@ async def to_code(configs): displays = [ await cg.get_variable(display) for display in config[df.CONF_DISPLAYS] ] + rotation_type = RotationType.ROTATION_UNUSED + # options will have CONF_ROTATION true if rotation is changed in an automation. + if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True: + if all( + get_display_metadata(str(disp)).has_hardware_rotation + for disp in displays + ): + rotation_type = RotationType.ROTATION_HARDWARE + df.LOGGER.info("LVGL will use hardware rotation via display driver") + else: + rotation_type = RotationType.ROTATION_SOFTWARE + if get_esp32_variant() == VARIANT_ESP32P4: + df.LOGGER.info("LVGL will use software rotation (PPA accelerated)") + else: + df.LOGGER.info("LVGL will use software rotation") lv_component = cg.new_Pvariable( config[CONF_ID], displays, @@ -330,8 +353,11 @@ async def to_code(configs): config[CONF_DRAW_ROUNDING], config[df.CONF_RESUME_ON_INPUT], config[df.CONF_UPDATE_WHEN_DISPLAY_IDLE], + rotation_type, ) await cg.register_component(lv_component, config) + if rotation := config.get(CONF_ROTATION): + cg.add(lv_component.set_rotation(rotation)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) lv_scr_act = get_screen_active(lv_component) @@ -375,7 +401,6 @@ async def to_code(configs): f"set_{trigger_name.removeprefix('on_')}_trigger", )(trigger_var) ) - await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) # This must be done after all widgets are created for comp in helpers.lvgl_components_required: @@ -392,6 +417,9 @@ async def to_code(configs): } & styles_used: df.add_define("LV_COLOR_SCREEN_TRANSP", "1") + if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): + df.add_define("LV_THEME_DEFAULT_DARK", "1") + # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending lv_image_formats = {"RGB565", "ARGB8888"} if { @@ -459,8 +487,11 @@ def add_hello_world(config): def _theme_schema(value): return cv.Schema( { - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() + cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, + **{ + cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) + for name, w in WIDGET_TYPES.items() + }, } )(value) @@ -473,6 +504,17 @@ LVGL_SCHEMA = cv.All( cv.polling_component_schema("1s") .extend( { + **{ + cv.Optional(event): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) + ), + } + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + + df.LV_DISPLAY_EVENT_TRIGGERS + }, cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), cv.GenerateID(df.CONF_DISPLAYS): display_schema, @@ -486,6 +528,7 @@ LVGL_SCHEMA = cv.All( ): cv.boolean, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 50e6db74b8..977f1af9b4 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -2,9 +2,11 @@ from collections.abc import Callable from typing import Any from esphome import automation +from esphome.automation import StatelessLambdaAction import esphome.codegen as cg +from esphome.components.display import validate_rotation import esphome.config_validation as cv -from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_TIMEOUT +from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT from esphome.core import Lambda from esphome.cpp_generator import TemplateArguments, get_variable from esphome.cpp_types import nullptr @@ -23,6 +25,7 @@ from .defines import ( PARTS, StaticCastExpression, add_warning, + get_options, ) from .lv_validation import lv_bool, lv_milliseconds from .lvcode import ( @@ -191,6 +194,32 @@ async def lvgl_is_idle(config, condition_id, template_arg, args): return var +def _validate_rotation(value): + # Note that we need rotation + get_options()[CONF_ROTATION] = True + return validate_rotation(value) + + +@automation.register_action( + "lvgl.display.set_rotation", + StatelessLambdaAction, + cv.maybe_simple_value( + LVGL_SCHEMA.extend( + { + cv.Required(CONF_ROTATION): _validate_rotation, + } + ), + key=CONF_ROTATION, + ), + synchronous=True, +) +async def lvgl_set_rotation(config, action_id, template_arg, args): + lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) + async with LambdaContext(args, where=action_id) as context: + lv_add(lv_comp.set_rotation(config[CONF_ROTATION])) + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + + @automation.register_action( "lvgl.widget.redraw", ObjUpdateAction, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 500ccb608a..ef29a99ddd 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -29,6 +29,7 @@ KEY_COLOR_FORMATS = "color_formats" KEY_LV_DEFINES = "lv_defines" KEY_REMAPPED_USES = "remapped_uses" KEY_UPDATED_WIDGETS = "updated_widgets" +KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" @@ -56,6 +57,10 @@ def add_warning(msg: str): get_warnings().add(msg) +def get_options(): + return get_data(KEY_OPTIONS) + + class StaticCastExpression(Expression): __slots__ = ("type", "exp") @@ -271,10 +276,6 @@ LV_EVENT_MAP = { "DRAW_POST_BEGIN": "DRAW_POST_BEGIN", "DRAW_POST_END": "DRAW_POST_END", "DRAW_TASK_ADD": "DRAW_TASK_ADDED", - "FLUSH_FINISH": "FLUSH_FINISH", - "FLUSH_START": "FLUSH_START", - "FLUSH_WAIT_FINISH": "FLUSH_WAIT_FINISH", - "FLUSH_WAIT_START": "FLUSH_WAIT_START", "FOCUS": "FOCUSED", "GESTURE": "GESTURE", "GET_SELF_SIZE": "GET_SELF_SIZE", @@ -295,18 +296,8 @@ LV_EVENT_MAP = { "READY": "READY", "REFRESH": "REFRESH", "REFR_EXT_DRAW_SIZE": "REFR_EXT_DRAW_SIZE", - "REFR_READY": "REFR_READY", - "REFR_REQUEST": "REFR_REQUEST", - "REFR_START": "REFR_START", "RELEASE": "RELEASED", - "RENDER_READY": "RENDER_READY", - "RENDER_START": "RENDER_START", - "RESOLUTION_CHANGE": "RESOLUTION_CHANGED", "ROTARY": "ROTARY", - "SCREEN_LOAD": "SCREEN_LOADED", - "SCREEN_LOAD_START": "SCREEN_LOAD_START", - "SCREEN_UNLOAD": "SCREEN_UNLOADED", - "SCREEN_UNLOAD_START": "SCREEN_UNLOAD_START", "SCROLL": "SCROLL", "SCROLL_BEGIN": "SCROLL_BEGIN", "SCROLL_END": "SCROLL_END", @@ -317,12 +308,34 @@ LV_EVENT_MAP = { "STATE_CHANGE": "STATE_CHANGED", "STYLE_CHANGE": "STYLE_CHANGED", "TRIPLE_CLICK": "TRIPLE_CLICKED", +} +LV_SCREEN_EVENT_MAP = { + "SCREEN_LOAD": "SCREEN_LOADED", + "SCREEN_LOAD_START": "SCREEN_LOAD_START", + "SCREEN_UNLOAD": "SCREEN_UNLOADED", + "SCREEN_UNLOAD_START": "SCREEN_UNLOAD_START", +} + +LV_DISPLAY_EVENT_MAP = { + "FLUSH_FINISH": "FLUSH_FINISH", + "FLUSH_START": "FLUSH_START", + "FLUSH_WAIT_FINISH": "FLUSH_WAIT_FINISH", + "FLUSH_WAIT_START": "FLUSH_WAIT_START", + "REFR_READY": "REFR_READY", + "REFR_REQUEST": "REFR_REQUEST", + "REFR_START": "REFR_START", + "RENDER_READY": "RENDER_READY", + "RENDER_START": "RENDER_START", + "RESOLUTION_CHANGE": "RESOLUTION_CHANGED", "UPDATE_LAYOUT_COMPLETE": "UPDATE_LAYOUT_COMPLETED", "VSYNC": "VSYNC", "VSYNC_REQUEST": "VSYNC_REQUEST", } LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT_MAP) +LV_DISPLAY_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_DISPLAY_EVENT_MAP) +LV_SCREEN_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_SCREEN_EVENT_MAP) + SWIPE_TRIGGERS = tuple( f"on_swipe_{x.lower()}" for x in DIRECTIONS.choices + ("up", "down") ) @@ -598,6 +611,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" CONF_GRID_CELL_ROW_POS = "grid_cell_row_pos" diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml index 4af179a589..bbbd34e30a 100644 --- a/esphome/components/lvgl/hello_world.yaml +++ b/esphome/components/lvgl/hello_world.yaml @@ -3,7 +3,7 @@ - obj: id: hello_world_card_ pad_all: 12 - bg_color: white + bg_opa: cover height: 100% width: 100% scrollable: false diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index a5075cb614..ce9b013dcf 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -83,6 +83,44 @@ std::string lv_event_code_name_for(lv_event_t *event) { return buf; } +void LvglComponent::set_rotation(display::DisplayRotation rotation) { + if (this->rotation_type_ == RotationType::ROTATION_UNUSED) { + ESP_LOGW(TAG, "Display rotation cannot be changed unless rotation was enabled during setup."); + return; + } + this->rotation_ = rotation; + if (this->is_ready()) { + this->set_resolution_(); + lv_obj_update_layout(this->get_screen_active()); + lv_obj_invalidate(this->get_screen_active()); + } +} + +void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const { + switch (this->rotation_) { + default: + break; + + case display::DISPLAY_ROTATION_180_DEGREES: { + x = this->width_ - x - 1; + y = this->height_ - y - 1; + break; + } + case display::DISPLAY_ROTATION_270_DEGREES: { + auto tmp = x; + x = this->height_ - y - 1; + y = tmp; + break; + } + case display::DISPLAY_ROTATION_90_DEGREES: { + auto tmp = y; + y = this->width_ - x - 1; + x = tmp; + break; + } + } +} + static void rounder_cb(lv_event_t *event) { auto *comp = static_cast(lv_event_get_user_data(event)); auto *area = static_cast(lv_event_get_param(event)); @@ -118,7 +156,18 @@ void LvglComponent::dump_config() { " Buffer size: %zu%%\n" " Rotation: %d\n" " Draw rounding: %d", - this->width_, this->height_, 100 / this->buffer_frac_, this->rotation, (int) this->draw_rounding); + this->width_, this->height_, 100 / this->buffer_frac_, this->rotation_, (int) this->draw_rounding); + if (this->rotation_type_ != ROTATION_UNUSED) { + const char *rot_type = "hardware via display driver"; + if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { +#ifdef USE_ESP32_VARIANT_ESP32P4 + rot_type = this->ppa_client_ != nullptr ? "software (PPA accelerated)" : "software"; +#else + rot_type = "software"; +#endif + } + ESP_LOGCONFIG(TAG, " Rotation type: %s", rot_type); + } } void LvglComponent::set_paused(bool paused, bool show_snow) { @@ -210,54 +259,145 @@ void LvglComponent::show_prev_page(lv_screen_load_anim_t anim, uint32_t time) { size_t LvglComponent::get_current_page() const { return this->current_page_; } bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; } +#ifdef USE_ESP32_VARIANT_ESP32P4 +bool LvglComponent::ppa_rotate_(const lv_color_data *src, lv_color_data *dst, uint16_t width, uint16_t height, + uint32_t height_rounded) { + ppa_srm_rotation_angle_t angle; + uint16_t out_w, out_h; + + // Map ESPHome clockwise display rotation to PPA counter-clockwise angles + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + angle = PPA_SRM_ROTATION_ANGLE_270; // 270° CCW = 90° CW + out_w = height_rounded; + out_h = width; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + angle = PPA_SRM_ROTATION_ANGLE_180; + out_w = width; + out_h = height; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + angle = PPA_SRM_ROTATION_ANGLE_90; // 90° CCW = 270° CW + out_w = height_rounded; + out_h = width; + break; + default: + return false; // No rotation needed + } + + // Align buffer size to cache line (LV_DRAW_BUF_ALIGN) as required by PPA DMA + // the underlying buffer will be large enough as the size is also padded when allocating. + size_t out_buf_size = out_w * out_h * sizeof(lv_color_data); + out_buf_size = LV_ROUND_UP(out_buf_size, LV_DRAW_BUF_ALIGN); + + ppa_srm_oper_config_t srm_config{}; + srm_config.in.buffer = src; + srm_config.in.pic_w = width; + srm_config.in.pic_h = height; + srm_config.in.block_w = width; + srm_config.in.block_h = height; +#if LV_COLOR_DEPTH == 16 + srm_config.in.srm_cm = PPA_SRM_COLOR_MODE_RGB565; +#elif LV_COLOR_DEPTH == 32 + srm_config.in.srm_cm = PPA_SRM_COLOR_MODE_ARGB8888; +#endif + srm_config.out.buffer = dst; + srm_config.out.buffer_size = out_buf_size; + srm_config.out.pic_w = out_w; + srm_config.out.pic_h = out_h; +#if LV_COLOR_DEPTH == 16 + srm_config.out.srm_cm = PPA_SRM_COLOR_MODE_RGB565; +#elif LV_COLOR_DEPTH == 32 + srm_config.out.srm_cm = PPA_SRM_COLOR_MODE_ARGB8888; +#endif + srm_config.rotation_angle = angle; + srm_config.scale_x = 1.0f; + srm_config.scale_y = 1.0f; + srm_config.mode = PPA_TRANS_MODE_BLOCKING; + + esp_err_t ret = ppa_do_scale_rotate_mirror(this->ppa_client_, &srm_config); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "PPA rotation failed: %s", esp_err_to_name(ret)); + ESP_LOGW(TAG, "PPA SRM: in=%ux%u src=%p, out=%ux%u dst=%p size=%zu, angle=%d", width, height, src, out_w, out_h, + dst, out_buf_size, (int) angle); + return false; + } + return true; +} +#endif // USE_ESP32_VARIANT_ESP32P4 + void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { auto width = lv_area_get_width(area); auto height = lv_area_get_height(area); auto height_rounded = (height + this->draw_rounding - 1) / this->draw_rounding * this->draw_rounding; auto x1 = area->x1; auto y1 = area->y1; - lv_color_data *dst = reinterpret_cast(this->rotate_buf_); - switch (this->rotation) { - case display::DISPLAY_ROTATION_90_DEGREES: - for (lv_coord_t x = height; x-- != 0;) { - for (lv_coord_t y = 0; y != width; y++) { - dst[y * height_rounded + x] = *ptr++; - } - } - y1 = x1; - x1 = this->height_ - area->y1 - height; - height = width; - width = height_rounded; - break; + if (this->rotation_type_ == ROTATION_SOFTWARE) { + lv_color_data *dst = reinterpret_cast(this->rotate_buf_); +#ifdef USE_ESP32_VARIANT_ESP32P4 + bool ppa_done = this->ppa_client_ != nullptr && this->ppa_rotate_(ptr, dst, width, height, height_rounded); + if (!ppa_done) +#endif + { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + for (lv_coord_t x = height; x-- != 0;) { + for (lv_coord_t y = 0; y != width; y++) { + dst[y * height_rounded + x] = *ptr++; + } + } + break; - case display::DISPLAY_ROTATION_180_DEGREES: - for (lv_coord_t y = height; y-- != 0;) { - for (lv_coord_t x = width; x-- != 0;) { - dst[y * width + x] = *ptr++; - } - } - x1 = this->width_ - x1 - width; - y1 = this->height_ - y1 - height; - break; + case display::DISPLAY_ROTATION_180_DEGREES: + for (lv_coord_t y = height; y-- != 0;) { + for (lv_coord_t x = width; x-- != 0;) { + dst[y * width + x] = *ptr++; + } + } + break; - case display::DISPLAY_ROTATION_270_DEGREES: - for (lv_coord_t x = 0; x != height; x++) { - for (lv_coord_t y = width; y-- != 0;) { - dst[y * height_rounded + x] = *ptr++; - } - } - x1 = y1; - y1 = this->width_ - area->x1 - width; - height = width; - width = height_rounded; - break; + case display::DISPLAY_ROTATION_270_DEGREES: + for (lv_coord_t x = 0; x != height; x++) { + for (lv_coord_t y = width; y-- != 0;) { + dst[y * height_rounded + x] = *ptr++; + } + } + break; - default: - dst = ptr; - break; + default: + dst = ptr; + break; + } + } + // Coordinate adjustments apply regardless of PPA or SW rotation + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + y1 = x1; + x1 = this->width_ - area->y1 - height; + height = width; + width = height_rounded; + break; + + case display::DISPLAY_ROTATION_180_DEGREES: + x1 = this->width_ - x1 - width; + y1 = this->height_ - y1 - height; + break; + + case display::DISPLAY_ROTATION_270_DEGREES: + x1 = y1; + y1 = this->height_ - area->x1 - width; + height = width; + width = height_rounded; + break; + + default: + break; + } + ptr = dst; } for (auto *display : this->displays_) { - display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) dst, display::COLOR_ORDER_RGB, LV_BITNESS, + display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) ptr, display::COLOR_ORDER_RGB, LV_BITNESS, this->big_endian_); } } @@ -272,7 +412,7 @@ void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uin lv_display_flush_ready(disp_drv); } -IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeout) : timeout_(std::move(timeout)) { +IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableFn timeout) : timeout_(timeout) { parent->add_on_idle_callback([this](uint32_t idle_time) { if (!this->is_idle_ && idle_time > this->timeout_.value()) { this->is_idle_ = true; @@ -297,6 +437,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r if (l->touch_pressed_) { data->point.x = l->touch_point_.x; data->point.y = l->touch_point_.y; + l->parent_->rotate_coordinates(data->point.x, data->point.y); data->state = LV_INDEV_STATE_PRESSED; } else { data->state = LV_INDEV_STATE_RELEASED; @@ -543,26 +684,44 @@ void LvglComponent::write_random_() { * multiple of 2, and so on. * @param resume_on_input if true, this component will resume rendering when the user * presses a key or clicks on the screen. + * @param rotation_type What rotation type to use, if any */ LvglComponent::LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, - int draw_rounding, bool resume_on_input, bool update_when_display_idle) + int draw_rounding, bool resume_on_input, bool update_when_display_idle, + RotationType rotation_type) : draw_rounding(draw_rounding), displays_(std::move(displays)), buffer_frac_(buffer_frac), full_refresh_(full_refresh), resume_on_input_(resume_on_input), - update_when_display_idle_(update_when_display_idle) { + update_when_display_idle_(update_when_display_idle), + rotation_type_(rotation_type) { this->disp_ = lv_display_create(240, 240); } +void LvglComponent::set_resolution_() const { + int32_t width = this->width_; + int32_t height = this->height_; + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) { + std::swap(width, height); + } + ESP_LOGD(TAG, "Setting resolution to %u x %u (rotation %d)", (unsigned) width, (unsigned) height, + (int) this->rotation_); + if (this->rotation_type_ == RotationType::ROTATION_HARDWARE) { + for (auto *display : this->displays_) + display->set_rotation(this->rotation_); + } + lv_display_set_resolution(this->disp_, width, height); +} void LvglComponent::setup() { auto *display = this->displays_[0]; auto rounding = this->draw_rounding; + this->width_ = display->get_native_width(); + this->height_ = display->get_native_height(); // cater for displays with dimensions that don't divide by the required rounding - this->width_ = display->get_width(); - this->height_ = display->get_height(); - auto width = (display->get_width() + rounding - 1) / rounding * rounding; - auto height = (display->get_height() + rounding - 1) / rounding * rounding; + auto width = (this->width_ + rounding - 1) / rounding * rounding; + auto height = (this->height_ + rounding - 1) / rounding * rounding; auto frac = this->buffer_frac_; if (frac == 0) frac = 1; @@ -586,21 +745,29 @@ void LvglComponent::setup() { return; } this->draw_buf_ = static_cast(buffer); - lv_display_set_resolution(this->disp_, this->width_, this->height_); + this->set_resolution_(); lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); lv_display_set_flush_cb(this->disp_, static_flush_cb); lv_display_set_user_data(this->disp_, this); lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes, this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL); - this->rotation = display->get_rotation(); - if (this->rotation != display::DISPLAY_ROTATION_0_DEGREES) { + if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { this->rotate_buf_ = static_cast(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT if (this->rotate_buf_ == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } +#ifdef USE_ESP32_VARIANT_ESP32P4 + ppa_client_config_t ppa_config{}; + ppa_config.oper_type = PPA_OPERATION_SRM; + ppa_config.max_pending_trans_num = 1; + if (ppa_register_client(&ppa_config, &this->ppa_client_) != ESP_OK) { + ESP_LOGW(TAG, "PPA client registration failed, using software rotation"); + this->ppa_client_ = nullptr; + } +#endif } if (this->draw_start_callback_ != nullptr) { lv_display_add_event_cb(this->disp_, render_start_cb, LV_EVENT_RENDER_START, this); @@ -620,9 +787,6 @@ void LvglComponent::setup() { esp_log_printf_(LOG_LEVEL_MAP[level], TAG, 0, "%.*s", (int) strlen(buf) - 1, buf); }); #endif - // Rotation will be handled by our drawing function, so reset the display rotation. - for (auto *disp : this->displays_) - disp->set_rotation(display::DISPLAY_ROTATION_0_DEGREES); this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0); lv_display_trigger_activity(this->disp_); } @@ -744,7 +908,7 @@ static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT static void *lv_alloc_draw_buf(size_t size, bool internal) { void *buffer; - size = ((size + LV_DRAW_BUF_ALIGN - 1) / LV_DRAW_BUF_ALIGN) * LV_DRAW_BUF_ALIGN; + size = LV_ROUND_UP(size, LV_DRAW_BUF_ALIGN); buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT if (buffer == nullptr) ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : ""); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8d139b23cb..3ba258b1a2 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -26,6 +26,10 @@ #include #include +#ifdef USE_ESP32_VARIANT_ESP32P4 +#include "driver/ppa.h" +#endif + #ifdef USE_FONT #include "esphome/components/font/font.h" #endif // USE_LVGL_FONT @@ -156,13 +160,18 @@ template class ObjUpdateAction : public Action { #ifdef USE_LVGL_ANIMIMG void lv_animimg_stop(lv_obj_t *obj); #endif // USE_LVGL_ANIMIMG +enum RotationType : uint8_t { + ROTATION_UNUSED, + ROTATION_SOFTWARE, + ROTATION_HARDWARE, +}; class LvglComponent : public PollingComponent { constexpr static const char *const TAG = "lvgl"; public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, - bool resume_on_input, bool update_when_display_idle); + bool resume_on_input, bool update_when_display_idle, RotationType rotation_type); static void static_flush_cb(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p); float get_setup_priority() const override { return setup_priority::PROCESSOR; } @@ -216,13 +225,19 @@ class LvglComponent : public PollingComponent { // rounding factor to align bounds of update area when drawing size_t draw_rounding{2}; - display::DisplayRotation rotation{display::DISPLAY_ROTATION_0_DEGREES}; void set_pause_trigger(Trigger<> *trigger) { this->pause_callback_ = trigger; } void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } + void set_rotation(display::DisplayRotation rotation); + display::DisplayRotation get_rotation() const { return this->rotation_; } + void rotate_coordinates(int32_t &x, int32_t &y) const; + + uint16_t get_width() const { return lv_display_get_horizontal_resolution(this->disp_); } + uint16_t get_height() const { return lv_display_get_vertical_resolution(this->disp_); } protected: + void set_resolution_() const; void draw_end_(); // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case @@ -230,6 +245,10 @@ class LvglComponent : public PollingComponent { void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); +#ifdef USE_ESP32_VARIANT_ESP32P4 + bool ppa_rotate_(const lv_color_data *src, lv_color_data *dst, uint16_t width, uint16_t height, + uint32_t height_rounded); +#endif void flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p); std::vector displays_{}; @@ -256,14 +275,19 @@ class LvglComponent : public PollingComponent { Trigger<> *draw_start_callback_{}; Trigger<> *draw_end_callback_{}; void *rotate_buf_{}; + display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES}; + RotationType rotation_type_; +#ifdef USE_ESP32_VARIANT_ESP32P4 + ppa_client_handle_t ppa_client_{}; +#endif }; class IdleTrigger : public Trigger<> { public: - explicit IdleTrigger(LvglComponent *parent, TemplatableValue timeout); + explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); protected: - TemplatableValue timeout_; + TemplatableFn timeout_; bool is_idle_{}; }; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 4f1473b652..2c57452a55 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -23,6 +23,7 @@ from esphome.core.config import StartupTrigger from . import defines as df, lv_validation as lvalid from .defines import ( + CONF_EXT_CLICK_AREA, CONF_SCROLL_DIR, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, @@ -311,6 +312,7 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant( "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO" ).one_of, + cv.Optional(CONF_EXT_CLICK_AREA): lvalid.pixels, cv.Optional(CONF_SCROLL_DIR): df.SCROLL_DIRECTIONS.one_of, cv.Optional(CONF_SCROLL_SNAP_X): df.SNAP_DIRECTIONS.one_of, cv.Optional(CONF_SCROLL_SNAP_Y): df.SNAP_DIRECTIONS.one_of, @@ -318,6 +320,7 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex ) OBJ_PROPERTIES = { + CONF_EXT_CLICK_AREA, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, CONF_SCROLL_DIR, @@ -433,7 +436,6 @@ def obj_schema(widget_type: WidgetType): return ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) - .extend({cv.Optional(df.CONF_EXT_CLICK_AREA): lvalid.pixels}) .extend(automation_schema(widget_type.w_type)) .extend( { diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 793290de73..c17f30383b 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -7,7 +7,7 @@ from esphome.core import ID from .defines import CONF_STYLE_DEFINITIONS, CONF_THEME, LValidator, literal from .helpers import add_lv_use from .lvcode import LambdaContext, lv -from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, remap_property +from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property from .types import ObjUpdateAction, lv_style_t from .widgets import collect_parts, theme_widget_map, wait_for_widgets @@ -85,7 +85,7 @@ async def style_update_to_code(config, action_id, template_arg, args): async def theme_to_code(config): if theme := config.get(CONF_THEME): add_lv_use(CONF_THEME) - for w_name, style in theme.items(): + for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES): # Work around Python 3.10 bug with nested async comprehensions # With Python 3.11 this could be simplified # TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index c52d213e15..f825999e8a 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -8,17 +8,21 @@ from esphome.const import ( CONF_X, CONF_Y, ) -from esphome.cpp_generator import new_Pvariable +from esphome.cpp_generator import MockObj, new_Pvariable from esphome.cpp_helpers import register_component +from esphome.cpp_types import nullptr from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, - CONF_EXT_CLICK_AREA, DIRECTIONS, + LV_DISPLAY_EVENT_MAP, + LV_DISPLAY_EVENT_TRIGGERS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, + LV_SCREEN_EVENT_MAP, + LV_SCREEN_EVENT_TRIGGERS, SWIPE_TRIGGERS, literal, ) @@ -31,6 +35,7 @@ from .lvcode import ( lv, lv_add, lv_event_t_ptr, + lv_expr, lvgl_static, ) from .types import LV_EVENT @@ -50,25 +55,24 @@ async def generate_triggers(): Must be done after all widgets completed """ + all_triggers = ( + LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS + ) for w in widget_map.values(): + config = w.config if isinstance(w.type, LvScrActType): w = get_screen_active(w.var) - if w.config: + if config: for event, conf in { - event: conf - for event, conf in w.config.items() - if event in LV_EVENT_TRIGGERS + event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] w.add_flag("LV_OBJ_FLAG_CLICKABLE") - event = literal("LV_EVENT_" + LV_EVENT_MAP[event[3:].upper()]) await add_trigger(conf, w, event) for event, conf in { - event: conf - for event, conf in w.config.items() - if event in SWIPE_TRIGGERS + event: conf for event, conf in config.items() if event in SWIPE_TRIGGERS }.items(): conf = conf[0] dir = event[9:].upper() @@ -78,11 +82,9 @@ async def generate_triggers(): selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) - await add_trigger( - conf, w, literal("LV_EVENT_GESTURE"), is_selected=selected - ) + await add_trigger(conf, w, "GESTURE", is_selected=selected) - for conf in w.config.get(CONF_ON_VALUE, ()): + for conf in config.get(CONF_ON_VALUE, ()): await add_trigger( conf, w, @@ -91,7 +93,7 @@ async def generate_triggers(): UPDATE_EVENT, ) - await add_on_boot_triggers(w.config.get(CONF_ON_BOOT, ())) + await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) async def generate_align_tos(config: dict): @@ -114,14 +116,23 @@ async def generate_align_tos(config: dict): x = align_to[CONF_X] y = align_to[CONF_Y] lv.obj_align_to(w.obj, target, align, x, y) - if ext_click_area := w.config.get(CONF_EXT_CLICK_AREA): - lv.obj_set_ext_click_area(w.obj, ext_click_area) action_id = config[CONF_ALIGN_TO_LAMBDA_ID] var = new_Pvariable(action_id, await context.get_lambda()) await register_component(var, {}) +TRIGGER_MAP = LV_EVENT_MAP | LV_DISPLAY_EVENT_MAP | LV_SCREEN_EVENT_MAP +DISPLAY_TRIGGERS = set(LV_DISPLAY_EVENT_TRIGGERS) + + +def _get_event_literal(trigger: str | MockObj) -> MockObj: + if isinstance(trigger, MockObj): + return trigger + trigger = trigger.removeprefix("on_") + return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()]) + + async def add_trigger(conf, w, *events, is_selected=None): is_selected = is_selected or w.is_selected() tid = conf[CONF_TRIGGER_ID] @@ -132,4 +143,14 @@ async def add_trigger(conf, w, *events, is_selected=None): async with LambdaContext(EVENT_ARG, where=tid) as context: with LvConditional(is_selected): lv_add(trigger.trigger(*value, literal("event"))) - lv_add(lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *events)) + callback = await context.get_lambda() + event_literals = [_get_event_literal(event) for event in events] + if isinstance(events[0], str) and events[0] in DISPLAY_TRIGGERS: + assert len(events) == 1 + lv.display_add_event_cb( + lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr + ) + else: + lv_add( + lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *event_literals) + ) diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 686e429267..0c8ddfbfbd 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -69,6 +69,7 @@ lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") lv_event_t = LvType("lv_event_t") +RotationType = lvgl_ns.enum("RotationType") LV_EVENT = MockObj(base="LV_EVENT_", op="") LV_STATE = MockObj(base="LV_STATE_", op="") diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 7629b03e9d..108bb38df5 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -2,6 +2,7 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_BUTTON, CONF_ID, CONF_INDEX, CONF_ITEMS, @@ -73,7 +74,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return CONF_BUTTONMATRIX, TYPE_FLEX + return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON async def to_code(self, w: Widget, config: dict): await w.set_property( diff --git a/esphome/components/max31855/sensor.py b/esphome/components/max31855/sensor.py index 93e48beee0..35ae28d04c 100644 --- a/esphome/components/max31855/sensor.py +++ b/esphome/components/max31855/sensor.py @@ -19,6 +19,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ) .extend( { diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index be6390fc17..e9fae4cceb 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -117,7 +117,7 @@ async def max6956_pin_to_code(config): async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, float) + template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) cg.add(var.set_brightness_global(template_)) return var @@ -139,6 +139,8 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_BRIGHTNESS_MODE], args, float) + template_ = await cg.templatable( + config[CONF_BRIGHTNESS_MODE], args, MAX6956_CURRENTMODE + ) cg.add(var.set_brightness_mode(template_)) return var diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index eb751b995d..df2423b0d0 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -147,7 +147,8 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( async def max7219digit_invert_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - cg.add(var.set_state(config[CONF_STATE])) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) + cg.add(var.set_state(template_)) return var @@ -166,7 +167,8 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): async def max7219digit_visible_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - cg.add(var.set_state(config[CONF_STATE])) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) + cg.add(var.set_state(template_)) return var @@ -185,7 +187,8 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): async def max7219digit_reverse_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - cg.add(var.set_state(config[CONF_STATE])) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) + cg.add(var.set_state(template_)) return var diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 64b120daa4..5f73e03f6f 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -22,9 +22,14 @@ void MCP23008::setup() { // enable open-drain interrupt pins, 3.3V-safe this->write_reg(mcp23x08_base::MCP23X08_IOCON, iocon | IOCON_ODR); } + + this->setup_interrupt_pin_(); } -void MCP23008::dump_config() { ESP_LOGCONFIG(TAG, "MCP23008:"); } +void MCP23008::dump_config() { + ESP_LOGCONFIG(TAG, "MCP23008:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} bool MCP23008::read_reg(uint8_t reg, uint8_t *value) { if (this->is_failed()) diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index e14e317d44..212c15ccf2 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -6,7 +6,8 @@ namespace mcp23017 { static const char *const TAG = "mcp23017"; -static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin +static constexpr uint8_t IOCON_MIRROR = 0x40; // Mirror INTA/INTB pins +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin void MCP23017::setup() { uint8_t iocon; @@ -19,14 +20,26 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon | IOCON_ODR); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon | IOCON_ODR); + iocon_flags |= IOCON_ODR; } + if (this->interrupt_pin_ != nullptr) { + // Mirror INTA/INTB so either pin fires for changes on any port + iocon_flags |= IOCON_MIRROR; + } + if (iocon_flags != 0) { + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon | iocon_flags); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon | iocon_flags); + } + + this->setup_interrupt_pin_(); } -void MCP23017::dump_config() { ESP_LOGCONFIG(TAG, "MCP23017:"); } +void MCP23017::dump_config() { + ESP_LOGCONFIG(TAG, "MCP23017:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} bool MCP23017::read_reg(uint8_t reg, uint8_t *value) { if (this->is_failed()) diff --git a/esphome/components/mcp23s08/__init__.py b/esphome/components/mcp23s08/__init__.py index 3d4e304f9b..312da79b75 100644 --- a/esphome/components/mcp23s08/__init__.py +++ b/esphome/components/mcp23s08/__init__.py @@ -18,7 +18,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(mcp23S08), - cv.Optional(CONF_DEVICEADDRESS, default=0): cv.uint8_t, + cv.Optional(CONF_DEVICEADDRESS, default=0): cv.int_range(min=0, max=3), } ) .extend(mcp23xxx_base.MCP23XXX_CONFIG_SCHEMA) diff --git a/esphome/components/mcp23s08/mcp23s08.cpp b/esphome/components/mcp23s08/mcp23s08.cpp index 1c17b66637..983c1aa600 100644 --- a/esphome/components/mcp23s08/mcp23s08.cpp +++ b/esphome/components/mcp23s08/mcp23s08.cpp @@ -34,11 +34,14 @@ void MCP23S08::setup() { // enable open-drain interrupt pins, 3.3V-safe (addressed, only this chip) this->write_reg(mcp23x08_base::MCP23X08_IOCON, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); } + + this->setup_interrupt_pin_(); } void MCP23S08::dump_config() { ESP_LOGCONFIG(TAG, "MCP23S08:"); LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); } bool MCP23S08::read_reg(uint8_t reg, uint8_t *value) { diff --git a/esphome/components/mcp23s17/__init__.py b/esphome/components/mcp23s17/__init__.py index ea8433af2e..599bfa0851 100644 --- a/esphome/components/mcp23s17/__init__.py +++ b/esphome/components/mcp23s17/__init__.py @@ -18,7 +18,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(mcp23S17), - cv.Optional(CONF_DEVICEADDRESS, default=0): cv.uint8_t, + cv.Optional(CONF_DEVICEADDRESS, default=0): cv.int_range(min=0, max=7), } ) .extend(mcp23xxx_base.MCP23XXX_CONFIG_SCHEMA) diff --git a/esphome/components/mcp23s17/mcp23s17.cpp b/esphome/components/mcp23s17/mcp23s17.cpp index c6abd7ad59..db9a34e230 100644 --- a/esphome/components/mcp23s17/mcp23s17.cpp +++ b/esphome/components/mcp23s17/mcp23s17.cpp @@ -7,9 +7,10 @@ namespace mcp23s17 { static const char *const TAG = "mcp23s17"; // IOCON register bits -static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode -static constexpr uint8_t IOCON_HAEN = 0x08; // Hardware address enable -static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin +static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode +static constexpr uint8_t IOCON_MIRROR = 0x40; // Mirror INTA/INTB pins +static constexpr uint8_t IOCON_HAEN = 0x08; // Hardware address enable +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin void MCP23S17::set_device_address(uint8_t device_addr) { if (device_addr != 0) { @@ -37,16 +38,26 @@ void MCP23S17::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + uint8_t iocon_flags = IOCON_SEQOP | IOCON_HAEN; if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe (addressed, only this chip) - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); + iocon_flags |= IOCON_ODR; } + if (this->interrupt_pin_ != nullptr) { + // Mirror INTA/INTB so either pin fires for changes on any port + iocon_flags |= IOCON_MIRROR; + } + if (this->open_drain_ints_ || this->interrupt_pin_ != nullptr) { + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon_flags); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon_flags); + } + + this->setup_interrupt_pin_(); } void MCP23S17::dump_config() { ESP_LOGCONFIG(TAG, "MCP23S17:"); LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); } bool MCP23S17::read_reg(uint8_t reg, uint8_t *value) { diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 92228be62c..197b739204 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -32,6 +32,16 @@ void MCP23X08Base::pin_mode(uint8_t pin, gpio::Flags flags) { } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg(pin, false, iodir); } + // When interrupt_pin is configured, auto-enable CHANGE interrupt for input pins + // so the chip's INT output fires on any input state change + if (this->interrupt_pin_ != nullptr && (flags & gpio::FLAG_INPUT)) { + this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); + } + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr && (flags & gpio::FLAG_INPUT)) { + this->enable_loop(); + } } void MCP23X08Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) { diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 6f95ee98fd..efed7f5f17 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -44,6 +44,16 @@ void MCP23X17Base::pin_mode(uint8_t pin, gpio::Flags flags) { } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg(pin, false, iodir); } + // When interrupt_pin is configured, auto-enable CHANGE interrupt for input pins + // so the chip's INT output fires on any input state change + if (this->interrupt_pin_ != nullptr && (flags & gpio::FLAG_INPUT)) { + this->pin_interrupt_mode(pin, mcp23xxx_base::MCP23XXX_CHANGE); + } + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr && (flags & gpio::FLAG_INPUT)) { + this->enable_loop(); + } } void MCP23X17Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) { diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d6e82101ad..cd952099c0 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -5,6 +5,7 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -32,6 +33,7 @@ MCP23XXX_INTERRUPT_MODES = { MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ).extend(cv.COMPONENT_SCHEMA) @@ -43,6 +45,8 @@ async def register_mcp23xxx(config, num_pins): await cg.register_component(var, config) CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) return var diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index 535119fc5c..4c1daac562 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -7,7 +7,12 @@ namespace mcp23xxx_base { template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); - this->parent_->pin_interrupt_mode(this->pin_, this->interrupt_mode_); + // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE + // interrupt for input pins, so skip the explicit call if the user didn't + // override the default (NO_INTERRUPT) + if (this->interrupt_mode_ != MCP23XXX_NO_INTERRUPT || this->parent_->get_interrupt_pin() == nullptr) { + this->parent_->pin_interrupt_mode(this->pin_, this->interrupt_mode_); + } } template void MCP23XXXGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } template bool MCP23XXXGPIOPin::digital_read() { diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index fb992466d5..6efd04e246 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -15,11 +15,34 @@ template class MCP23XXXBase : public Component, public gpio_expander: virtual void pin_interrupt_mode(uint8_t pin, MCP23XXXInterruptMode interrupt_mode); void set_open_drain_ints(const bool value) { this->open_drain_ints_ = value; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + InternalGPIOPin *get_interrupt_pin() const { return this->interrupt_pin_; } float get_setup_priority() const override { return setup_priority::IO; } - void loop() override { this->reset_pin_cache_(); } + void loop() override { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } + } protected: + // No need to clear latched interrupts before attaching the ISR — if INT is + // already low the ISR fires immediately, loop runs, cache invalidates, and + // the GPIO read clears the latch. One harmless extra read at most. + void setup_interrupt_pin_() { + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); + } + static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } + // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; // write a value to a given register @@ -28,6 +51,7 @@ template class MCP23XXXBase : public Component, public gpio_expander: virtual void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) = 0; bool open_drain_ints_; + InternalGPIOPin *interrupt_pin_{nullptr}; }; template class MCP23XXXGPIOPin : public GPIOPin { 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/mdns/__init__.py b/esphome/components/mdns/__init__.py index 0d535d6970..79d355e8ae 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import LambdaExpression from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -131,6 +132,12 @@ def mdns_service( Returns: A StructInitializer representing a MDNSService struct """ + # Wrap port in a stateless lambda for TemplatableFn storage. + # Can't use cg.templatable() here because this is a sync function. + if not isinstance(port, LambdaExpression): + port = LambdaExpression( + f"return {cg.safe_exp(port)};", [], capture="", return_type=cg.uint16 + ) return cg.StructInitializer( MDNSService, ("service_type", cg.RawExpression(f"MDNS_STR({cg.safe_exp(service)})")), diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 342a6e6c64..e05373ac5d 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -57,7 +57,7 @@ void MDNSComponent::compile_records_(StaticVectorget_port(); + service.port = []() -> uint16_t { return api::global_api_server->get_port(); }; const auto &friendly_name = App.get_friendly_name(); bool friendly_name_empty = friendly_name.empty(); @@ -151,7 +151,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; #endif #ifdef USE_SENDSPIN @@ -162,7 +162,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; #endif @@ -172,7 +172,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ @@ -185,7 +185,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; #endif } @@ -199,7 +199,7 @@ void MDNSComponent::dump_config() { ESP_LOGV(TAG, " Services:"); for (const auto &service : this->services_) { ESP_LOGV(TAG, " - %s, %s, %d", MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), - const_cast &>(service.port).value()); + service.port.value()); for (const auto &record : service.txt_records) { ESP_LOGV(TAG, " TXT: %s = %s", MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value)); } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 47cad4bf71..adf88a9cf1 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -36,7 +36,7 @@ struct MDNSService { // second label indicating protocol _including_ underscore character prefix // as defined in RFC6763 Section 7, like "_tcp" or "_udp" const MDNSString *proto; - TemplatableValue port; + TemplatableFn port; FixedVector txt_records; }; diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 3e997402bc..17000a2bd7 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -37,7 +37,7 @@ static void register_esp32(MDNSComponent *comp, StaticVector &>(service.port).value(); + uint16_t port = service.port.value(); err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, txt_records.get(), service.txt_records.size()); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 295a408cbd..70c614f8d3 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -27,7 +27,7 @@ static void register_esp8266(MDNSComponent *, StaticVector &>(service.port).value(); + uint16_t port = service.port.value(); MDNS.addService(FPSTR(service_type), FPSTR(proto), port); for (const auto &record : service.txt_records) { MDNS.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)), diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index 986099fa1f..a543a3809a 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -27,7 +27,7 @@ static void register_libretiny(MDNSComponent *, StaticVector &>(service.port).value(); + uint16_t port_ = service.port.value(); MDNS.addService(service_type, proto, port_); for (const auto &record : service.txt_records) { MDNS.addServiceTxt(service_type, proto, MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value)); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 88f707afd3..64b603030c 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -32,7 +32,7 @@ static void register_rp2040(MDNSComponent *, StaticVector &>(service.port).value(); + uint16_t port = service.port.value(); MDNS.addService(service_type, proto, port); for (const auto &record : service.txt_records) { MDNS.addServiceTxt(service_type, proto, MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value)); diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 842f620dae..1c2c474645 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): @@ -268,7 +305,7 @@ _register_state_conditions() async def media_player_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, float) + volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) cg.add(var.set_volume(volume)) return var diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 372eb4c3b0..ff27dec6df 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition import esphome.codegen as cg -from esphome.components import esp32, microphone, ota, socket +from esphome.components import esp32, microphone, ota import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kahrendt", "@jesserockz"] DEPENDENCIES = ["microphone"] -AUTO_LOAD = ["socket"] + DOMAIN = "micro_wake_word" @@ -405,7 +405,7 @@ def _model_config_to_manifest_data(model_config): file = _compute_local_file_path(model_config) / "manifest.json" else: - raise ValueError("Unsupported config type: {model_config[CONF_TYPE]}") + raise ValueError(f"Unsupported config type: {model_config[CONF_TYPE]}") return _load_model_data(file) @@ -444,10 +444,6 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Enable wake_loop_threadsafe() for low-latency wake word detection - # The inference task queues detection events that need immediate processing - socket.require_wake_loop_threadsafe() - mic_source = await microphone.microphone_source_to_code(config[CONF_MICROPHONE]) cg.add(var.set_microphone_source(mic_source)) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index b93bf1b556..f1aac875f1 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -431,9 +431,7 @@ void MicroWakeWord::process_probabilities_() { xQueueSend(this->detection_queue_, &wake_word_state, portMAX_DELAY); // Wake main loop immediately to process wake word detection -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif model->reset_probabilities(); #ifdef USE_MICRO_WAKE_WORD_VAD 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/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index d903db4a1b..8b20a562c8 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -168,8 +168,8 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_BOOST); if (capabilities.supportEcoPreset()) traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_ECO); - if (capabilities.supportFrostProtectionPreset()) - traits.set_supported_custom_presets({Constants::FREEZE_PROTECTION}); + // Frost protection custom preset is handled by AirConditioner directly + // since custom presets are stored on the Climate base class } } // namespace ac diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 4d59a4fbbc..50521cf238 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -24,6 +24,25 @@ template void update_property(T &property, const T &value, bool &fla } void AirConditioner::on_status_change() { + // Add frost protection custom preset once when autoconf completes + if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && + this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { + // Read existing presets (set by codegen), append frost protection, write back + const auto &existing = this->get_traits().get_supported_custom_presets(); + bool found = false; + for (const char *p : existing) { + if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { + found = true; + break; + } + } + if (!found) { + std::vector merged(existing.begin(), existing.end()); + merged.push_back(Constants::FREEZE_PROTECTION); + this->set_supported_custom_presets(merged); + } + this->frost_protection_set_ = true; + } bool need_publish = false; update_property(this->target_temperature, this->base_.getTargetTemp(), need_publish); update_property(this->current_temperature, this->base_.getIndoorTemp(), need_publish); @@ -91,17 +110,15 @@ ClimateTraits AirConditioner::traits() { traits.set_supported_modes(this->supported_modes_); traits.set_supported_swing_modes(this->supported_swing_modes_); traits.set_supported_presets(this->supported_presets_); - if (!this->supported_custom_presets_.empty()) - traits.set_supported_custom_presets(this->supported_custom_presets_); - if (!this->supported_custom_fan_modes_.empty()) - traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); + // Custom fan modes and presets are stored on Climate base class and wired via get_traits() /* + MINIMAL SET OF CAPABILITIES */ traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_AUTO); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_LOW); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_MEDIUM); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_HIGH); - if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) + if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { Converters::to_climate_traits(traits, this->base_.getCapabilities()); + } if (!traits.get_supported_modes().empty()) traits.add_supported_mode(ClimateMode::CLIMATE_MODE_OFF); if (!traits.get_supported_swing_modes().empty()) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 70833b8bcc..8dbc71b422 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -46,8 +46,8 @@ class AirConditioner : public ApplianceBase, void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } - void set_custom_presets(std::initializer_list presets) { this->supported_custom_presets_ = presets; } - void set_custom_fan_modes(std::initializer_list modes) { this->supported_custom_fan_modes_ = modes; } + void set_custom_presets(std::initializer_list presets) { this->set_supported_custom_presets(presets); } + void set_custom_fan_modes(std::initializer_list modes) { this->set_supported_custom_fan_modes(modes); } protected: void control(const ClimateCall &call) override; @@ -55,8 +55,7 @@ class AirConditioner : public ApplianceBase, ClimateModeMask supported_modes_{}; ClimateSwingModeMask supported_swing_modes_{}; ClimatePresetMask supported_presets_{}; - std::vector supported_custom_presets_{}; - std::vector supported_custom_fan_modes_{}; + bool frost_protection_set_{false}; Sensor *outdoor_sensor_{nullptr}; Sensor *humidity_sensor_{nullptr}; Sensor *power_sensor_{nullptr}; diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 4dbc81caa2..ccd43c72cf 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -128,6 +128,8 @@ MADCTL_MH = 0x04 # Bit 2 LCD refresh right to left MADCTL_XFLIP = 0x02 # Mirror the display horizontally MADCTL_YFLIP = 0x01 # Mirror the display vertically +MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips + # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay @@ -329,7 +331,13 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config) -> tuple[int, int, int, int]: + def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + """ + Return the dimensions of the current model. + :param config: The current configuration + :param swap: If width/height should be swapped when axes are swapped. + :return: + """ if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -361,13 +369,12 @@ class DriverChip: ) offset_height = native_height - height - offset_height # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer - if transform.get(CONF_SWAP_XY) is True: + if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height return width, height, offset_width, offset_height - def get_transform(self, config) -> dict[str, bool]: - can_transform = self.rotation_as_transform(config) + def get_base_transform(self, config): transform = config.get( CONF_TRANSFORM, { @@ -376,14 +383,20 @@ class DriverChip: CONF_SWAP_XY: self.get_default(CONF_SWAP_XY), }, ) - if not isinstance(transform, dict): - # Presumably disabled - return { - CONF_MIRROR_X: False, - CONF_MIRROR_Y: False, - CONF_SWAP_XY: False, - CONF_TRANSFORM: False, - } + if isinstance(transform, dict): + return transform + + # Transform is disabled + return { + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + CONF_SWAP_XY: False, + CONF_TRANSFORM: False, + } + + def get_transform(self, config) -> dict[str, bool]: + transform = self.get_base_transform(config) + can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? if can_transform and CONF_TRANSFORM not in config: rotation = config[CONF_ROTATION] @@ -411,11 +424,15 @@ class DriverChip: return {cv.Required(CONF_SWAP_XY): cv.boolean} return {cv.Optional(CONF_SWAP_XY, default=False): validator} - def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - use_flip = config.get(CONF_USE_AXIS_FLIPS) - madctl = 0 - transform = self.get_transform(config) + def get_madctl(self, transform: dict, config: dict) -> int: + """ + Convert a transform to MADCTL bits + :param transform: The transform dict + :param use_flip: Whether to use axis flips + :return: MADCTL value + """ + use_flip = config.get(CONF_USE_AXIS_FLIPS, False) + madctl = MADCTL_FLIP_FLAG if use_flip else 0 if transform[CONF_MIRROR_X]: madctl |= MADCTL_XFLIP if use_flip else MADCTL_MX if transform[CONF_MIRROR_Y]: @@ -424,22 +441,28 @@ class DriverChip: madctl |= MADCTL_MV if config[CONF_COLOR_ORDER] == MODE_BGR: madctl |= MADCTL_BGR - sequence.append((MADCTL, madctl)) return madctl + def add_madctl(self, sequence: list, config: dict): + # Add the MADCTL command to the sequence based on the configuration. + # This takes into account rotation if it can be implemented in the transform + transform = self.get_transform(config) + madctl = self.get_madctl(transform, config) + sequence.append((MADCTL, madctl & 0xFF)) + def skip_command(self, command: str): """ Allow suppressing a standard command in the init sequence. """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config) -> tuple[tuple[int, ...], int]: + def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence - Pixel format, color order, and orientation will be set. - Returns a tuple of the init sequence and the computed MADCTL value. + MADCTL will be set if add_madctl is True + Returns the init sequence """ sequence = list(self.initsequence or ()) custom_sequence = config.get(CONF_INIT_SEQUENCE, []) @@ -457,7 +480,8 @@ class DriverChip: if self.rotation_as_transform(config): LOGGER.info("Using hardware transform to implement rotation") - madctl = self.add_madctl(sequence, config) + if add_madctl: + self.add_madctl(sequence, config) if config[CONF_INVERT_COLORS]: sequence.append((INVON,)) else: @@ -471,7 +495,7 @@ class DriverChip: # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed - return flatten_sequence(sequence), madctl + return flatten_sequence(sequence) def requires_buffer(config) -> bool: diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 85bfad7f1a..026c214569 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -192,10 +192,9 @@ async def to_code(config): width, height, _offset_width, _offset_height = model.get_dimensions(config) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) - sequence, madctl = model.get_sequence(config) + sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_init_sequence(sequence)) - cg.add(var.set_madctl(madctl)) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index e8e9ca2bfb..fc59aeffe8 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -392,9 +392,6 @@ void MIPI_DSI::dump_config() { "\n Model: %s" "\n Width: %u" "\n Height: %u" - "\n Mirror X: %s" - "\n Mirror Y: %s" - "\n Swap X/Y: %s" "\n Rotation: %d degrees" "\n DSI Lanes: %u" "\n Lane Bit Rate: %.0fMbps" @@ -406,14 +403,11 @@ void MIPI_DSI::dump_config() { "\n VSync Front Porch: %u" "\n Buffer Color Depth: %d bit" "\n Display Pixel Mode: %d bit" - "\n Color Order: %s" "\n Invert Colors: %s" "\n Pixel Clock: %.1fMHz", - this->model_, this->width_, this->height_, YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)), - YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY)), YESNO(this->madctl_ & MADCTL_MV), this->rotation_, - this->lanes_, this->lane_bit_rate_, this->hsync_pulse_width_, this->hsync_back_porch_, - this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, this->vsync_front_porch_, - (3 - this->color_depth_) * 8, this->pixel_mode_, this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", + this->model_, this->width_, this->height_, this->rotation_, this->lanes_, this->lane_bit_rate_, + this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, + this->vsync_back_porch_, this->vsync_front_porch_, (3 - this->color_depth_) * 8, this->pixel_mode_, YESNO(this->invert_colors_), this->pclk_frequency_); LOG_PIN(" Reset Pin ", this->reset_pin_); } diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 6e27912aa5..c27c9ccc6e 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -60,7 +60,6 @@ class MIPI_DSI : public display::Display { void set_model(const char *model) { this->model_ = model; } void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } - void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } void smark_failed(const LogString *message, esp_err_t err); @@ -86,7 +85,6 @@ class MIPI_DSI : public display::Display { std::vector enable_pins_{}; size_t width_{}; size_t height_{}; - uint8_t madctl_{}; uint16_t hsync_pulse_width_ = 10; uint16_t hsync_back_porch_ = 10; uint16_t hsync_front_porch_ = 20; diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 0aa8c56719..4952bda95f 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -265,9 +265,8 @@ async def to_code(config): if CONF_SPI_ID in config: await spi.register_spi_device(var, config, write_only=True) - sequence, madctl = model.get_sequence(config) + sequence = model.get_sequence(config) cg.add(var.set_init_sequence(sequence)) - cg.add(var.set_madctl(madctl)) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 0b0a5344e4..6f5e2f2490 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -118,15 +118,7 @@ void MipiRgbSpi::dump_config() { MipiRgb::dump_config(); LOG_PIN(" CS Pin: ", this->cs_); LOG_PIN(" DC Pin: ", this->dc_pin_); - ESP_LOGCONFIG(TAG, - " SPI Data rate: %uMHz" - "\n Mirror X: %s" - "\n Mirror Y: %s" - "\n Swap X/Y: %s" - "\n Color Order: %s", - (unsigned) (this->data_rate_ / 1000000), YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)), - YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY | MADCTL_ML)), YESNO(this->madctl_ & MADCTL_MV), - this->madctl_ & MADCTL_BGR ? "BGR" : "RGB"); + ESP_LOGCONFIG(TAG, " SPI Data rate: %uMHz", (unsigned) (this->data_rate_ / 1000000)); } #endif // USE_SPI diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 76b48bb249..accc251a18 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -38,7 +38,6 @@ class MipiRgb : public display::Display { display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } - void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } void add_data_pin(InternalGPIOPin *data_pin, size_t index) { this->data_pins_[index] = data_pin; }; void set_de_pin(InternalGPIOPin *de_pin) { this->de_pin_ = de_pin; } @@ -84,7 +83,6 @@ class MipiRgb : public display::Display { uint16_t vsync_front_porch_ = 10; uint32_t pclk_frequency_ = 16 * 1000 * 1000; bool pclk_inverted_{true}; - uint8_t madctl_{}; const char *model_{"Unknown"}; bool invert_colors_{}; display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8dccfa3a92..42c7ec2224 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -10,7 +10,7 @@ from esphome.components.const import ( CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, ) -from esphome.components.display import CONF_SHOW_TEST_CARD, DISPLAY_ROTATIONS +from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.mipi import ( CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -47,12 +47,10 @@ from esphome.const import ( CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, - CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import CORE from esphome.cpp_generator import TemplateArguments from esphome.final_validate import full_config @@ -113,22 +111,21 @@ DISPLAY_PIXEL_MODES = { def denominator(config): """ Calculate the best denominator for a buffer size fraction. - The denominator must be a number between 2 and 16 that divides the display height evenly, + The denominator should be a number between 2 and 16 that divides the display height evenly, and the fraction represented by the denominator must be less than or equal to the given fraction. :config: The configuration dictionary containing the buffer size fraction and display dimensions :return: The denominator to use for the buffer size fraction """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - if frac is None or frac > 0.75: + _width, height, _offset_width, _offset_height = model.get_dimensions(config) + if frac is None or frac > 0.75 or height < 32: return 1 - height, _width, _offset_width, _offset_height = model.get_dimensions(config) try: return next(x for x in range(2, 17) if frac >= 1 / x and height % x == 0) except StopIteration: - raise cv.Invalid( - f"Buffer size fraction {frac} is not compatible with display height {height}" - ) from StopIteration + # No exact divisor, just use the closest. + return next(x for x in range(2, 17) if frac >= 1 / x) def model_schema(config): @@ -282,75 +279,33 @@ def _final_validate(config): from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + if config[CONF_BUS_MODE] == TYPE_SINGLE: + spi.final_validate_device_schema(DOMAIN, require_miso=False, require_mosi=True)( + config + ) if not requires_buffer(config) and LVGL_DOMAIN not in global_config: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: - if not requires_buffer(config): - return config # No buffer needed, so no need to set a buffer size # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): - # not our problem. - return config + return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - height, width, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height = model.get_dimensions(config) buffer_size = color_depth // 8 * width * height // frac - # Target a buffer size of 20kB - fraction = 20000.0 / buffer_size - try: - config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x and height % x == 0 - ) - except StopIteration: - # Either the screen is too big, or the height is not divisible by any of the fractions, so use 1.0 - # PSRAM will be needed. - if CORE.is_esp32: - raise cv.Invalid( - "PSRAM is required for this display" - ) from StopIteration - - return config + # Target a buffer size of 20kB, except for large displays, which shouldn't end up here + fraction = min(20000.0, buffer_size // 16) / buffer_size + config[CONF_BUFFER_SIZE] = 1.0 / next( + x for x in range(2, 17) if fraction >= 1 / x + ) FINAL_VALIDATE_SCHEMA = _final_validate -def get_transform(config): - """ - Get the transformation configuration for the display. - :param config: - :return: - """ - model = MODELS[config[CONF_MODEL]] - can_transform = model.rotation_as_transform(config) - transform = config.get( - CONF_TRANSFORM, - { - CONF_MIRROR_X: model.get_default(CONF_MIRROR_X, False), - CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y, False), - CONF_SWAP_XY: model.get_default(CONF_SWAP_XY, False), - }, - ) - - # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True - return transform - - def get_instance(config): """ Get the type of MipiSpi instance to create based on the configuration, @@ -359,7 +314,16 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - width, height, offset_width, offset_height = model.get_dimensions(config) + has_hardware_transform = config.get( + CONF_TRANSFORM + ) != CONF_DISABLED and model.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + width, height, offset_width, offset_height = model.get_dimensions( + config, not has_hardware_transform + ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) bufferpixels = COLOR_DEPTHS[color_depth] @@ -373,57 +337,43 @@ def get_instance(config): bus_type = BusTypes[bus_type] buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) - rotation = ( - 0 if model.rotation_as_transform(config) else config.get(CONF_ROTATION, 0) - ) + madctl = model.get_madctl(model.get_base_transform(config), config) + has_writer = requires_buffer(config) templateargs = [ buffer_type, bufferpixels, config[CONF_BYTE_ORDER] == "big_endian", display_pixel_mode, bus_type, + width, + height, + offset_width, + offset_height, + madctl, + has_hardware_transform, ] + display.add_metadata( + config[CONF_ID], width, height, has_writer, has_hardware_transform + ) # If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi if requires_buffer(config): templateargs.extend( [ - width, - height, - offset_width, - offset_height, - DISPLAY_ROTATIONS[rotation], frac, config[CONF_DRAW_ROUNDING], ] ) return MipiSpiBuffer, templateargs - # Swap height and width if the display is rotated 90 or 270 degrees in software - if rotation in (90, 270): - width, height = height, width - offset_width, offset_height = offset_height, offset_width - templateargs.extend( - [ - width, - height, - offset_width, - offset_height, - ] - ) return MipiSpi, templateargs async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] + init_sequence = model.get_sequence(config, False) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) - init_sequence, _madctl = model.get_sequence(config) cg.add(var.set_init_sequence(init_sequence)) - if model.rotation_as_transform(config): - if CONF_TRANSFORM in config: - LOGGER.warning("Use of 'transform' with 'rotation' is not recommended") - else: - config[CONF_ROTATION] = 0 cg.add(var.set_model(config[CONF_MODEL])) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] diff --git a/esphome/components/mipi_spi/mipi_spi.cpp b/esphome/components/mipi_spi/mipi_spi.cpp index 90f6324511..2eec3b12d1 100644 --- a/esphome/components/mipi_spi/mipi_spi.cpp +++ b/esphome/components/mipi_spi/mipi_spi.cpp @@ -5,7 +5,8 @@ namespace esphome::mipi_spi { void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl, bool invert_colors, int display_bits, bool is_big_endian, const optional &brightness, - GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width) { + GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, + bool has_hardware_rotation) { ESP_LOGCONFIG(TAG, "MIPI_SPI Display\n" " Model: %s\n" @@ -14,6 +15,7 @@ void internal_dump_config(const char *model, int width, int height, int offset_w " Swap X/Y: %s\n" " Mirror X: %s\n" " Mirror Y: %s\n" + " Hardware rotation: %s\n" " Invert colors: %s\n" " Color order: %s\n" " Display pixels: %d bits\n" @@ -22,9 +24,9 @@ void internal_dump_config(const char *model, int width, int height, int offset_w " SPI Data rate: %uMHz\n" " SPI Bus width: %d", model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)), - YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(invert_colors), (madctl & MADCTL_BGR) ? "BGR" : "RGB", - display_bits, is_big_endian ? "Big" : "Little", spi_mode, static_cast(data_rate / 1000000), - bus_width); + YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(has_hardware_rotation), YESNO(invert_colors), + (madctl & MADCTL_BGR) ? "BGR" : "RGB", display_bits, is_big_endian ? "Big" : "Little", spi_mode, + static_cast(data_rate / 1000000), bus_width); LOG_PIN(" CS Pin: ", cs); LOG_PIN(" Reset Pin: ", reset); LOG_PIN(" DC Pin: ", dc); diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 083ff9507f..423226b1d7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -34,13 +34,14 @@ static constexpr uint8_t SWIRE1 = 0x5A; static constexpr uint8_t SWIRE2 = 0x5B; static constexpr uint8_t PAGESEL = 0xFE; -static constexpr uint8_t MADCTL_MY = 0x80; // Bit 7 Bottom to top -static constexpr uint8_t MADCTL_MX = 0x40; // Bit 6 Right to left -static constexpr uint8_t MADCTL_MV = 0x20; // Bit 5 Swap axes -static constexpr uint8_t MADCTL_RGB = 0x00; // Bit 3 Red-Green-Blue pixel order -static constexpr uint8_t MADCTL_BGR = 0x08; // Bit 3 Blue-Green-Red pixel order -static constexpr uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally -static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically +static constexpr uint8_t MADCTL_MY = 0x80; // Bit 7 Bottom to top +static constexpr uint8_t MADCTL_MX = 0x40; // Bit 6 Right to left +static constexpr uint8_t MADCTL_MV = 0x20; // Bit 5 Swap axes +static constexpr uint8_t MADCTL_RGB = 0x00; // Bit 3 Red-Green-Blue pixel order +static constexpr uint8_t MADCTL_BGR = 0x08; // Bit 3 Blue-Green-Red pixel order +static constexpr uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally +static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically +static constexpr uint16_t MADCTL_FLIP_FLAG = 0x100; // controller uses axis flip bits static constexpr uint8_t DELAY_FLAG = 0xFF; // store a 16 bit value in a buffer, big endian. @@ -66,7 +67,8 @@ enum BusType { // Helper function for dump_config - defined in mipi_spi.cpp to allow use of LOG_PIN macro void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl, bool invert_colors, int display_bits, bool is_big_endian, const optional &brightness, - GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width); + GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, + bool has_hardware_rotation); /** * Base class for MIPI SPI displays. @@ -83,7 +85,7 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -103,10 +105,39 @@ class MipiSpi : public display::Display, this->brightness_ = brightness; this->reset_params_(); } + void set_rotation(display::DisplayRotation rotation) override { + this->rotation_ = rotation; + if constexpr (HAS_HARDWARE_ROTATION) { + this->reset_params_(); + } + } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - int get_width_internal() override { return WIDTH; } - int get_height_internal() override { return HEIGHT; } + int get_width() override { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return HEIGHT; + return WIDTH; + } + + int get_height() override { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return WIDTH; + return HEIGHT; + } + + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -166,9 +197,6 @@ class MipiSpi : public display::Display, case INVERT_ON: this->invert_colors_ = true; break; - case MADCTL_CMD: - this->madctl_ = arg_byte; - break; case BRIGHTNESS: this->brightness_ = arg_byte; break; @@ -177,13 +205,13 @@ class MipiSpi : public display::Display, break; } const auto *ptr = vec.data() + index; - esph_log_d(TAG, "Command %02X, length %d, byte %02X", cmd, num_args, arg_byte); this->write_command_(cmd, ptr, num_args); index += num_args; if (cmd == SLEEP_OUT) delay(10); } } + this->reset_params_(); // init sequence no longer needed this->init_sequence_.clear(); } @@ -206,9 +234,10 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, this->madctl_, this->invert_colors_, - DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, - this->mode_, this->data_rate_, BUS_TYPE); + internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, + this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, + this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, + HAS_HARDWARE_ROTATION); } protected: @@ -219,10 +248,13 @@ class MipiSpi : public display::Display, // Writes a command to the display, with the given bytes. void write_command_(uint8_t cmd, const uint8_t *bytes, size_t len) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)]; - esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); -#endif + // Don't spam the log after setup + if (this->init_sequence_.empty()) { + esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); + } else { + esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); + } if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { this->enable(); this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); @@ -271,16 +303,60 @@ class MipiSpi : public display::Display, this->write_command_(this->invert_colors_ ? INVERT_ON : INVERT_OFF); if (this->brightness_.has_value()) this->write_command_(BRIGHTNESS, this->brightness_.value()); + + // calculate new madctl value from base value adjusted for rotation + uint8_t madctl = MADCTL; // lower 8 bits only + constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0; + constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX; + constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY; + if constexpr (HAS_HARDWARE_ROTATION) { + switch (this->rotation_) { + default: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + madctl ^= x_mask; // flip X axis + madctl ^= MADCTL_MV; // swap X and Y axes + break; + case display::DISPLAY_ROTATION_180_DEGREES: + madctl ^= x_mask; // flip X axis + madctl ^= y_mask; // flip Y axis + break; + case display::DISPLAY_ROTATION_270_DEGREES: + madctl ^= y_mask; // flip Y axis + madctl ^= MADCTL_MV; // swap X and Y axes + break; + } + } + esph_log_d(TAG, "Setting MADCTL for rotation %d, value %X", this->rotation_, madctl); + this->write_command_(MADCTL_CMD, madctl); + } + + uint16_t get_offset_width_() { + if constexpr (HAS_HARDWARE_ROTATION) { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return OFFSET_HEIGHT; + } + return OFFSET_WIDTH; + } + + uint16_t get_offset_height_() { + if constexpr (HAS_HARDWARE_ROTATION) { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return OFFSET_WIDTH; + } + return OFFSET_HEIGHT; } // set the address window for the next data write void set_addr_window_(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { esph_log_v(TAG, "Set addr %d/%d, %d/%d", x1, y1, x2, y2); uint8_t buf[4]; - x1 += OFFSET_WIDTH; - x2 += OFFSET_WIDTH; - y1 += OFFSET_HEIGHT; - y2 += OFFSET_HEIGHT; + x1 += get_offset_width_(); + x2 += get_offset_width_(); + y1 += get_offset_height_(); + y2 += get_offset_height_(); put16_be(buf, y1); put16_be(buf + 2, y2); this->write_command_(RASET, buf, sizeof buf); @@ -408,7 +484,6 @@ class MipiSpi : public display::Display, optional brightness_{}; const char *model_{"Unknown"}; std::vector init_sequence_{}; - uint8_t madctl_{}; }; /** @@ -427,22 +502,21 @@ class MipiSpi : public display::Display, * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> class MipiSpiBuffer : public MipiSpi { + OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and // ignore the extra columns and rows when drawing, but use them to write to the display. - static constexpr unsigned BUFFER_WIDTH = (WIDTH + ROUNDING - 1) / ROUNDING * ROUNDING; - static constexpr unsigned BUFFER_HEIGHT = (HEIGHT + ROUNDING - 1) / ROUNDING * ROUNDING; + static constexpr size_t round_buffer(size_t size) { return (size + ROUNDING - 1) / ROUNDING * ROUNDING; } - MipiSpiBuffer() { this->rotation_ = ROTATION; } + MipiSpiBuffer() = default; void dump_config() override { - MipiSpi::dump_config(); + MipiSpi::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -450,14 +524,14 @@ class MipiSpiBuffer : public MipiSpirotation_, BUFFERPIXEL * 8, FRACTION, - sizeof(BUFFERTYPE) * BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION, ROUNDING); + sizeof(BUFFERTYPE) * round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION, ROUNDING); } void setup() override { - MipiSpi::setup(); + MipiSpi::setup(); RAMAllocator allocator{}; - this->buffer_ = allocator.allocate(BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION); + this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { this->mark_failed(LOG_STR("Buffer allocation failed")); } @@ -472,11 +546,13 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < HEIGHT; this->start_line_ += HEIGHT / FRACTION) { + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); + this->start_line_ += this->get_height_internal() / FRACTION) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = this->start_line_ + HEIGHT / FRACTION; + this->end_line_ = + clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -503,10 +579,10 @@ class MipiSpiBuffer : public MipiSpix_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, BUFFER_WIDTH - w); + this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); // invalidate watermarks - this->x_low_ = WIDTH; - this->y_low_ = HEIGHT; + this->x_low_ = this->get_width_internal(); + this->y_low_ = this->get_height_internal(); this->x_high_ = 0; this->y_high_ = 0; #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE @@ -523,10 +599,23 @@ class MipiSpiBuffer : public MipiSpiget_clipping().inside(x, y)) return; - rotate_coordinates(x, y); - if (x < 0 || x >= WIDTH || y < this->start_line_ || y >= this->end_line_) + if constexpr (not HAS_HARDWARE_ROTATION) { + if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { + x = WIDTH - x - 1; + y = HEIGHT - y - 1; + } else if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES) { + auto tmp = x; + x = WIDTH - y - 1; + y = tmp; + } else if (this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) { + auto tmp = y; + y = HEIGHT - x - 1; + x = tmp; + } + } + if (x < 0 || x >= this->get_width_internal() || y < this->start_line_ || y >= this->end_line_) return; - this->buffer_[(y - this->start_line_) * BUFFER_WIDTH + x] = convert_color(color); + this->buffer_[(y - this->start_line_) * round_buffer(this->get_width_internal()) + x] = convert_color(color); if (x < this->x_low_) { this->x_low_ = x; } @@ -551,39 +640,14 @@ class MipiSpiBuffer : public MipiSpix_low_ = 0; this->y_low_ = this->start_line_; - this->x_high_ = WIDTH - 1; + this->x_high_ = this->get_width_internal() - 1; this->y_high_ = this->end_line_ - 1; - std::fill_n(this->buffer_, HEIGHT * BUFFER_WIDTH / FRACTION, convert_color(color)); - } - - int get_width() override { - if constexpr (ROTATION == display::DISPLAY_ROTATION_90_DEGREES || ROTATION == display::DISPLAY_ROTATION_270_DEGREES) - return HEIGHT; - return WIDTH; - } - - int get_height() override { - if constexpr (ROTATION == display::DISPLAY_ROTATION_90_DEGREES || ROTATION == display::DISPLAY_ROTATION_270_DEGREES) - return WIDTH; - return HEIGHT; + std::fill_n(this->buffer_, (this->end_line_ - this->start_line_) * round_buffer(this->get_width_internal()), + convert_color(color)); } protected: // Rotate the coordinates to match the display orientation. - static void rotate_coordinates(int &x, int &y) { - if constexpr (ROTATION == display::DISPLAY_ROTATION_180_DEGREES) { - x = WIDTH - x - 1; - y = HEIGHT - y - 1; - } else if constexpr (ROTATION == display::DISPLAY_ROTATION_90_DEGREES) { - auto tmp = x; - x = WIDTH - y - 1; - y = tmp; - } else if constexpr (ROTATION == display::DISPLAY_ROTATION_270_DEGREES) { - auto tmp = y; - y = HEIGHT - x - 1; - x = tmp; - } - } // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 5ea72d4cd2..7fa6825ea6 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -8,6 +8,8 @@ DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] CODEOWNERS = ["@crnjan"] +CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval" + mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") MitsubishiCN105Climate = mitsubishi_ns.class_( @@ -20,7 +22,14 @@ MitsubishiCN105Climate = mitsubishi_ns.class_( CONFIG_SCHEMA = ( climate.climate_schema(MitsubishiCN105Climate) .extend(uart.UART_DEVICE_SCHEMA) - .extend({cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval}) + .extend( + { + cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, + cv.Optional( + CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" + ): cv.update_interval, + } + ) ) FINAL_VALIDATE_SCHEMA = cv.All( @@ -39,3 +48,8 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) + cg.add( + var.set_current_temperature_min_interval( + config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] + ) + ) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 35ab405b2d..1a35495618 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,7 +1,510 @@ +#include +#include +#include #include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.driver"; +static constexpr uint32_t WRITE_TIMEOUT_MS = 2000; + +static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + +static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; +static constexpr size_t HEADER_LEN = 5; +static constexpr uint8_t PREAMBLE = 0xFC; +static constexpr uint8_t HEADER_BYTE_1 = 0x01; +static constexpr uint8_t HEADER_BYTE_2 = 0x30; + +static constexpr uint8_t PACKET_TYPE_CONNECT_REQUEST = 0x5A; +static constexpr uint8_t PACKET_TYPE_CONNECT_RESPONSE = 0x7A; +static constexpr std::array CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01}; + +static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42; +static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62; +static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02; +static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; + +static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; +static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; + +static constexpr std::array, 9> PROTOCOL_MODE_MAP = { + std::nullopt, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + std::nullopt, // 0x04 + std::nullopt, // 0x05 + std::nullopt, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 +}; + +static constexpr std::array, 7> PROTOCOL_FAN_MODE_MAP = { + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + std::nullopt, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 +}; + +template +static constexpr std::optional lookup(const std::array, N> &table, uint8_t value) { + return (value < N) ? table[value] : std::nullopt; +} + +template +static constexpr bool reverse_lookup(const std::array, N> &table, T value, uint8_t &placeholder) { + for (size_t i = 0; i < N; ++i) { + const auto &table_value = table[i]; + if (table_value.has_value() && table_value == value) { + placeholder = i; + return true; + } + } + return false; +} + +static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { + return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); +} + +template +static constexpr auto make_packet(uint8_t type, const std::array &payload) { + const size_t full_len = PayloadSize + HEADER_LEN + 1; + std::array packet{PREAMBLE, type, HEADER_BYTE_1, HEADER_BYTE_2, static_cast(PayloadSize)}; + std::copy_n(payload.begin(), PayloadSize, packet.begin() + HEADER_LEN); + packet.back() = checksum(packet.data(), packet.size() - 1); + return packet; +} + +static float decode_temperature(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; +} + +static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD); + +void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); } + +bool MitsubishiCN105::update() { + if (const auto start = this->status_update_start_ms_) { + if (this->pending_updates_.any()) { + this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS); + return false; + } + + if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) { + this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS); + return false; + } + } + + if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { + this->write_timeout_start_ms_.reset(); + this->frame_parser_.reset(); + this->set_state_(State::READ_TIMEOUT); + return false; + } + + return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) { + return this->process_rx_packet_(type, payload, len); + }); +} + +void MitsubishiCN105::set_state_(State new_state) { + if (should_transition(this->state_, new_state)) { + ESP_LOGV(TAG, "Did transition: %s -> %s", LOG_STR_ARG(state_to_string(this->state_)), + LOG_STR_ARG(state_to_string(new_state))); + this->state_ = new_state; + this->did_transition_(new_state); + } else { + ESP_LOGV(TAG, "Ignoring unexpected transition %s -> %s", LOG_STR_ARG(state_to_string(this->state_)), + LOG_STR_ARG(state_to_string(new_state))); + } +} + +bool MitsubishiCN105::should_transition(State from, State to) { + switch (to) { + case State::CONNECTING: + return from == State::NOT_CONNECTED || from == State::READ_TIMEOUT; + + case State::CONNECTED: + return from == State::CONNECTING; + + case State::UPDATING_STATUS: + return from == State::CONNECTED || from == State::STATUS_UPDATED || + from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; + + case State::STATUS_UPDATED: + return from == State::UPDATING_STATUS; + + case State::SCHEDULE_NEXT_STATUS_UPDATE: + return from == State::STATUS_UPDATED || from == State::SETTINGS_APPLIED; + + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + return from == State::SCHEDULE_NEXT_STATUS_UPDATE; + + case State::APPLYING_SETTINGS: + return from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE || from == State::STATUS_UPDATED; + + case State::SETTINGS_APPLIED: + return from == State::APPLYING_SETTINGS; + + case State::READ_TIMEOUT: + return from == State::UPDATING_STATUS || from == State::APPLYING_SETTINGS || from == State::CONNECTING; + + default: + return false; + } +} + +void MitsubishiCN105::did_transition_(State to) { + switch (to) { + case State::CONNECTING: + this->send_packet_(CONNECT_PACKET); + break; + + case State::CONNECTED: + this->write_timeout_start_ms_.reset(); + this->current_status_msg_type_ = STATUS_MSG_SETTINGS; + this->set_state_(State::UPDATING_STATUS); + break; + + case State::UPDATING_STATUS: + this->update_status_(); + break; + + case State::STATUS_UPDATED: { + this->write_timeout_start_ms_.reset(); + if (this->pending_updates_.any() && this->is_status_initialized()) { + this->set_state_(State::APPLYING_SETTINGS); + } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { + this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP; + this->set_state_(State::UPDATING_STATUS); + } else { + this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); + } + break; + } + + case State::SCHEDULE_NEXT_STATUS_UPDATE: + this->status_update_start_ms_ = get_loop_time_ms(); + this->current_status_msg_type_ = STATUS_MSG_SETTINGS; + this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + break; + + case State::APPLYING_SETTINGS: + this->apply_settings_(); + this->pending_updates_.clear(); + break; + + case State::SETTINGS_APPLIED: + this->write_timeout_start_ms_.reset(); + this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); + break; + + case State::READ_TIMEOUT: + this->set_state_(State::CONNECTING); + break; + + default: + break; + } +} + +bool MitsubishiCN105::should_request_room_temperature_() const { + if (!this->is_room_temperature_enabled()) { + return false; + } + + if (!this->last_room_temperature_update_ms_.has_value()) { + return true; + } + + return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_; +} + +void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { + FrameParser::dump_buffer_vv("TX", packet, len); + this->device_.write_array(packet, len); + this->write_timeout_start_ms_ = get_loop_time_ms(); +} + +void MitsubishiCN105::update_status_() { + std::array payload = {this->current_status_msg_type_}; + this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); +} + +void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) { + this->status_update_start_ms_.reset(); + this->set_state_(state); +} + +bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) { + switch (type) { + case PACKET_TYPE_CONNECT_RESPONSE: + this->set_state_(State::CONNECTED); + return false; + + case PACKET_TYPE_STATUS_RESPONSE: + return this->process_status_packet_(payload, len); + + case PACKET_TYPE_WRITE_SETTINGS_RESPONSE: + this->set_state_(State::SETTINGS_APPLIED); + return false; + + default: + ESP_LOGVV(TAG, "RX unknown packet type 0x%02X", type); + return false; + } +} + +bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) { + if (len == 0) { + ESP_LOGVV(TAG, "RX status packet too short"); + return false; + } + + const auto previous = this->status_; + const auto msg_type = payload[0]; + if (!this->parse_status_payload_(msg_type, payload + 1, len - 1)) { + return false; + } + + if (msg_type == this->current_status_msg_type_) { + this->set_state_(State::STATUS_UPDATED); + } + + bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || + previous.fan_mode != this->status_.fan_mode || + previous.target_temperature != this->status_.target_temperature; + + if (this->is_room_temperature_enabled()) { + changed |= previous.room_temperature != this->status_.room_temperature; + } + + return changed && this->is_status_initialized(); +} + +bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + switch (msg_type) { + case STATUS_MSG_SETTINGS: + return this->parse_status_settings_(payload, len); + + case STATUS_MSG_ROOM_TEMP: + return this->parse_status_room_temperature_(payload, len); + + default: + ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); + return false; + } +} + +bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { + if (len <= 10) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + + if (!this->pending_updates_.has(UpdateFlag::POWER)) { + this->status_.power_on = payload[2] != 0; + } + + this->use_temperature_encoding_b_ = payload[10] != 0; + if (!this->pending_updates_.has(UpdateFlag::TEMPERATURE)) { + this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + if (!this->pending_updates_.has(UpdateFlag::MODE)) { + const bool i_see = payload[3] > 0x08; + this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN); + } + + if (!this->pending_updates_.has(UpdateFlag::FAN)) { + this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN); + } + + return true; +} + +bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) { + if (len <= 5) { + ESP_LOGVV(TAG, "RX room temperature payload too short"); + return false; + } + + this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); + this->last_room_temperature_update_ms_ = get_loop_time_ms(); + + return true; +} + +void MitsubishiCN105::set_power(bool power_on) { + this->status_.power_on = power_on; + this->pending_updates_.set(UpdateFlag::POWER); +} + +void MitsubishiCN105::set_target_temperature(float target_temperature) { + if (target_temperature < 16 || target_temperature > 31) { + ESP_LOGD(TAG, "Setting temperature out-of-range: %.1f", target_temperature); + return; + } + this->status_.target_temperature = std::round(target_temperature); + this->pending_updates_.set(UpdateFlag::TEMPERATURE); +} + +void MitsubishiCN105::set_mode(Mode mode) { + uint8_t placeholder; + if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) { + ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); + return; + } + this->status_.mode = mode; + this->pending_updates_.set(UpdateFlag::MODE); +} + +void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { + uint8_t placeholder; + if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) { + ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); + return; + } + this->status_.fan_mode = fan_mode; + this->pending_updates_.set(UpdateFlag::FAN); +} + +void MitsubishiCN105::apply_settings_() { + std::array payload = {0x01}; + + if (this->pending_updates_.has(UpdateFlag::POWER)) { + payload[1] |= 0x01; + payload[3] = this->status_.power_on ? 0x01 : 0x00; + } + + if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) { + payload[1] |= 0x04; + if (this->use_temperature_encoding_b_) { + payload[14] = static_cast(this->status_.target_temperature * 2.0f + 128.0f); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - this->status_.target_temperature); + } + } + + if (this->pending_updates_.has(UpdateFlag::MODE) && + reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) { + payload[1] |= 0x02; + } + + if (this->pending_updates_.has(UpdateFlag::FAN) && + reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) { + payload[1] |= 0x08; + } + + this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); +} + +const LogString *MitsubishiCN105::state_to_string(State state) { + switch (state) { + case State::NOT_CONNECTED: + return LOG_STR("Not connected"); + case State::CONNECTING: + return LOG_STR("Connecting"); + case State::CONNECTED: + return LOG_STR("Connected"); + case State::UPDATING_STATUS: + return LOG_STR("UpdatingStatus"); + case State::STATUS_UPDATED: + return LOG_STR("StatusUpdated"); + case State::SCHEDULE_NEXT_STATUS_UPDATE: + return LOG_STR("ScheduleNextStatusUpdate"); + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + return LOG_STR("WaitingForScheduledStatusUpdate"); + case State::APPLYING_SETTINGS: + return LOG_STR("ApplyingSettings"); + case State::SETTINGS_APPLIED: + return LOG_STR("SettingsApplied"); + case State::READ_TIMEOUT: + return LOG_STR("ReadTimeout"); + } + return LOG_STR("Unknown"); +} + +template +bool MitsubishiCN105::FrameParser::read_and_parse(uart::UARTDevice &device, Callback &&callback) { + uint8_t watchdog = 64; + while (device.available() > 0 && watchdog-- > 0) { + uint8_t &value = this->read_buffer_[this->read_pos_]; + if (!device.read_byte(&value)) { + ESP_LOGW(TAG, "UART read failed while data available"); + return false; + } + + switch (++this->read_pos_) { + case 1: + if (value != PREAMBLE) { + this->reset_and_dump_buffer_("RX ignoring preamble"); + } + continue; + + case 2: + continue; + + case 3: + if (value != HEADER_BYTE_1) { + this->reset_and_dump_buffer_("RX invalid: header 1 mismatch"); + } + continue; + + case 4: + if (value != HEADER_BYTE_2) { + this->reset_and_dump_buffer_("RX invalid: header 2 mismatch"); + } + continue; + + case HEADER_LEN: + static_assert(READ_BUFFER_SIZE > HEADER_LEN); + if (this->read_buffer_[HEADER_LEN - 1] >= READ_BUFFER_SIZE - HEADER_LEN) { + this->reset_and_dump_buffer_("RX invalid: payload too large"); + } + continue; + + default: + break; + } + + const size_t len_without_checksum = HEADER_LEN + static_cast(this->read_buffer_[HEADER_LEN - 1]); + if (this->read_pos_ <= len_without_checksum) { + continue; + } + + if (checksum(this->read_buffer_, len_without_checksum) != value) { + this->reset_and_dump_buffer_("RX invalid: checksum mismatch"); + continue; + } + + dump_buffer_vv("RX", this->read_buffer_, this->read_pos_); + const bool processed = + callback(this->read_buffer_[1], this->read_buffer_ + HEADER_LEN, len_without_checksum - HEADER_LEN); + this->read_pos_ = 0; + return processed; + } + + return false; +} + +void MitsubishiCN105::FrameParser::reset_and_dump_buffer_(const char *prefix) { + dump_buffer_vv(prefix, this->read_buffer_, this->read_pos_); + this->read_pos_ = 0; +} + +void MitsubishiCN105::FrameParser::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; + ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); +#endif +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 6018dddbff..68d98bf6d9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,19 +1,140 @@ #pragma once +#include #include "esphome/components/uart/uart.h" namespace esphome::mitsubishi_cn105 { +uint32_t get_loop_time_ms(); + class MitsubishiCN105 { public: + enum class Mode : uint8_t { + HEAT, + DRY, + COOL, + FAN_ONLY, + AUTO, + UNKNOWN, + }; + + enum class FanMode : uint8_t { + AUTO, + QUIET, + SPEED_1, + SPEED_2, + SPEED_3, + SPEED_4, + UNKNOWN, + }; + + struct Status { + bool power_on{false}; + float target_temperature{NAN}; + Mode mode{Mode::UNKNOWN}; + FanMode fan_mode{FanMode::UNKNOWN}; + float room_temperature{NAN}; + }; + explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} + void initialize(); + bool update(); + uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } + uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; } + bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; } + void set_room_temperature_min_interval(uint32_t interval_ms) { + this->room_temperature_min_interval_ms_ = interval_ms; + } + + const Status &status() const { return this->status_; } + bool is_status_initialized() const { + return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature) + : !std::isnan(this->status_.target_temperature); + } + + void set_power(bool power_on); + void set_target_temperature(float target_temperature); + void set_mode(Mode mode); + void set_fan_mode(FanMode fan_mode); + protected: + enum class State : uint8_t { + NOT_CONNECTED, + CONNECTING, + CONNECTED, + UPDATING_STATUS, + STATUS_UPDATED, + SCHEDULE_NEXT_STATUS_UPDATE, + WAITING_FOR_SCHEDULED_STATUS_UPDATE, + APPLYING_SETTINGS, + SETTINGS_APPLIED, + READ_TIMEOUT + }; + + class FrameParser { + public: + template bool read_and_parse(uart::UARTDevice &device, Callback &&callback); + void reset() { read_pos_ = 0; } + static void dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len); + + protected: + void reset_and_dump_buffer_(const char *prefix); + + private: + static constexpr size_t READ_BUFFER_SIZE = 32; + uint8_t read_buffer_[READ_BUFFER_SIZE]; + uint8_t read_pos_{0}; + }; + + enum class UpdateFlag : uint8_t { + TEMPERATURE = 1 << 0, + POWER = 1 << 1, + MODE = 1 << 2, + FAN = 1 << 3, + }; + + struct UpdateFlags { + void set(UpdateFlag f) { flags_ |= static_cast(f); } + void clear() { flags_ = 0; } + bool any() const { return flags_ != 0; } + bool has(UpdateFlag f) const { return (flags_ & static_cast(f)) != 0; } + + protected: + uint8_t flags_{0}; + }; + + void set_state_(State new_state); + void did_transition_(State to); + bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); + bool process_status_packet_(const uint8_t *payload, size_t len); + bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); + bool parse_status_settings_(const uint8_t *payload, size_t len); + bool parse_status_room_temperature_(const uint8_t *payload, size_t len); + void send_packet_(const uint8_t *packet, size_t len); + void update_status_(); + void cancel_waiting_and_transition_to_(State state); + bool should_request_room_temperature_() const; + void apply_settings_(); + template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } + static bool should_transition(State from, State to); + static const LogString *state_to_string(State state); + uart::UARTDevice &device_; uint32_t update_interval_ms_{1000}; + uint32_t room_temperature_min_interval_ms_{60000}; + std::optional write_timeout_start_ms_; + std::optional status_update_start_ms_; + std::optional last_room_temperature_update_ms_; + Status status_{}; + State state_{State::NOT_CONNECTED}; + UpdateFlags pending_updates_; + bool use_temperature_encoding_b_{false}; + uint8_t current_status_msg_type_{0}; + FrameParser frame_parser_; }; } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 6d50296c8f..40ddb88a79 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,3 +1,4 @@ +#include #include "mitsubishi_cn105_climate.h" #include "esphome/core/log.h" @@ -5,8 +6,58 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; +static constexpr std::array MODE_MAP{ + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, + std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, + std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, + std::pair{MitsubishiCN105::Mode::FAN_ONLY, climate::CLIMATE_MODE_FAN_ONLY}, +}; + +static constexpr std::array FAN_MODE_MAP{ + std::pair{MitsubishiCN105::FanMode::AUTO, climate::CLIMATE_FAN_AUTO}, + std::pair{MitsubishiCN105::FanMode::QUIET, climate::CLIMATE_FAN_QUIET}, + std::pair{MitsubishiCN105::FanMode::SPEED_1, climate::CLIMATE_FAN_LOW}, + std::pair{MitsubishiCN105::FanMode::SPEED_2, climate::CLIMATE_FAN_MEDIUM}, + std::pair{MitsubishiCN105::FanMode::SPEED_3, climate::CLIMATE_FAN_MIDDLE}, + std::pair{MitsubishiCN105::FanMode::SPEED_4, climate::CLIMATE_FAN_HIGH}, +}; + +template +static bool map_lookup(const std::array, N> &map, A key, B &out) { + for (const auto &[from, to] : map) { + if (from == key) { + out = to; + return true; + } + } + return false; +} + +template +static constexpr std::optional reverse_map_lookup(const std::array, N> &map, Right key) { + for (const auto &entry : map) { + if (entry.second == key) { + return entry.first; + } + } + return std::nullopt; +} + +template +static constexpr std::optional reverse_map_lookup(const std::array, N> &map, + const std::optional &key) { + return key.has_value() ? reverse_map_lookup(map, *key) : std::nullopt; +} + void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); + if (this->hp_.is_room_temperature_enabled()) { + ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", + this->hp_.get_room_temperature_min_interval()); + } else { + ESP_LOGCONFIG(TAG, " Current temperature: disabled"); + } ESP_LOGCONFIG(TAG, " Update interval: %" PRIu32 " ms\n" " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", @@ -14,15 +65,95 @@ void MitsubishiCN105Climate::dump_config() { LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); } -void MitsubishiCN105Climate::setup() {} +void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } -void MitsubishiCN105Climate::loop() {} +void MitsubishiCN105Climate::loop() { + if (this->hp_.update()) { + this->apply_values_(); + } +} climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; + + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_AUTO, + }); + + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_MIDDLE, + climate::CLIMATE_FAN_HIGH, + }); + + traits.set_visual_min_temperature(16.0f); + traits.set_visual_max_temperature(31.0f); + traits.set_visual_temperature_step(1.0f); + + if (this->hp_.is_room_temperature_enabled()) { + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_visual_current_temperature_step(0.5f); + } + return traits; } -void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {} +void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { + if (const auto target_temperature = call.get_target_temperature()) { + this->hp_.set_target_temperature(*target_temperature); + } + + if (const auto mode = call.get_mode()) { + if (*mode == climate::CLIMATE_MODE_OFF) { + this->hp_.set_power(false); + } else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) { + this->hp_.set_power(true); + this->hp_.set_mode(*mapped); + } + } + + if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) { + this->hp_.set_fan_mode(*fan_mode); + } + + if (this->hp_.is_status_initialized()) { + this->apply_values_(); + } +} + +void MitsubishiCN105Climate::apply_values_() { + const auto &status = this->hp_.status(); + + this->target_temperature = status.target_temperature; + + if (this->hp_.is_room_temperature_enabled()) { + this->current_temperature = status.room_temperature; + } + + if (status.power_on) { + if (!map_lookup(MODE_MAP, status.mode, this->mode)) { + ESP_LOGD(TAG, "Unable to map mode"); + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + + climate::ClimateFanMode fan_mode; + if (map_lookup(FAN_MODE_MAP, status.fan_mode, fan_mode)) { + this->fan_mode = fan_mode; + } else { + ESP_LOGD(TAG, "Unable to map fan mode"); + } + + this->publish_state(); +} } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index 08b482025f..eee4c20966 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -19,8 +19,11 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public void control(const climate::ClimateCall &call) override; void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); } + void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); } protected: + void apply_values_(); + MitsubishiCN105 hp_; }; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp new file mode 100644 index 0000000000..0f3fcb5648 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp @@ -0,0 +1,7 @@ +#include "esphome/core/application.h" + +namespace esphome::mitsubishi_cn105 { + +uint32_t __attribute__((weak)) get_loop_time_ms() { return App.get_loop_component_start_time(); } + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 63b419cc98..59a80d9297 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, socket, speaker +from esphome.components import audio, esp32, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -111,9 +111,6 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config): - # Enable wake_loop_threadsafe for immediate command processing from other tasks - socket.require_wake_loop_threadsafe() - var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 0fabc68c70..741239a2dd 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -245,11 +245,9 @@ void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { uint32_t event_bits = xEventGroupGetBits(this->event_group_); if (!(event_bits & command_bit)) { xEventGroupSetBits(this->event_group_, command_bit); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) if (wake_loop) { App.wake_loop_threadsafe(); } -#endif } } @@ -533,9 +531,7 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { if (!(event_bits & MIXER_TASK_COMMAND_START)) { // Set MIXER_TASK_COMMAND_START bit if not already set, and then immediately wake for low latency xEventGroupSetBits(this->event_group_, MIXER_TASK_COMMAND_START); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif } return ESP_OK; diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 293a133c3d..a6330b1cc0 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_RESOLUTION, CONF_TEMPERATURE, CONF_TEMPERATURE_COMPENSATION, + DEVICE_CLASS_TEMPERATURE, ICON_MAGNET, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, @@ -107,6 +108,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, icon=ICON_THERMOMETER, + device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ).extend( cv.Schema( diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 5b3982cee6..6d2bafdd0e 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -45,6 +45,7 @@ heading_schema = sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, icon=ICON_SCREEN_ROTATION, accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, ) CONFIG_SCHEMA = ( 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/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 3c4ceaf62d..5c3b39c954 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -376,7 +376,7 @@ size_t ModbusController::create_register_ranges_() { while (ix != this->sensorset_.end()) { SensorItem *curr = *ix; - ESP_LOGV(TAG, "Register: 0x%X %d %d %d offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, + ESP_LOGV(TAG, "Register: 0x%X %d %d %zu offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, curr->offset, curr->get_register_size(), curr->offset, curr->skip_updates, curr); if (r.register_count == 0) { @@ -484,18 +484,18 @@ void ModbusController::dump_config() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); for (auto &it : this->sensorset_) { - ESP_LOGCONFIG(TAG, " Sensor type=%zu start=0x%X offset=0x%X count=%d size=%d", + ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X count=%d size=%zu", static_cast(it->register_type), it->start_address, it->offset, it->register_count, it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); for (auto &it : this->register_ranges_) { - ESP_LOGCONFIG(TAG, " Range type=%zu start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), + ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), it.start_address, it.register_count, it.skip_updates); } ESP_LOGCONFIG(TAG, "server registers"); for (auto &r : this->server_registers_) { - ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%zu register_count=%u", r->address, + ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } #endif @@ -524,7 +524,7 @@ void ModbusController::on_write_register_response(ModbusRegisterType register_ty void ModbusController::dump_sensors_() { ESP_LOGV(TAG, "sensors"); for (auto &it : this->sensorset_) { - ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%d offset=%d", it->start_address, it->register_count, + ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count, it->get_register_size(), it->offset); } } diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 1ec4afd997..27d212d58d 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -11,6 +11,7 @@ from .. import ( modbus_controller_ns, ) from ..const import ( + CONF_CUSTOM_COMMAND, CONF_MODBUS_CONTROLLER_ID, CONF_REGISTER_TYPE, CONF_USE_WRITE_MULTIPLE, @@ -35,6 +36,10 @@ CONFIG_SCHEMA = cv.typed_schema( "coil": output.BINARY_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( { cv.GenerateID(): cv.declare_id(ModbusBinaryOutput), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid( + "custom_command is not supported for outputs" + ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, } @@ -42,6 +47,10 @@ CONFIG_SCHEMA = cv.typed_schema( "holding": output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend( { cv.GenerateID(): cv.declare_id(ModbusFloatOutput), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid( + "custom_command is not supported for outputs" + ), cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( SENSOR_VALUE_TYPE ), diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 995357143e..93ecd31168 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -61,7 +61,7 @@ async def to_code(config): response_size = config[CONF_RESPONSE_SIZE] reg_count = config[CONF_REGISTER_COUNT] if reg_count == 0: - reg_count = response_size / 2 + reg_count = response_size // 2 var = cg.new_Pvariable( config[CONF_ID], config[CONF_REGISTER_TYPE], diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 4e84fb708c..323175917d 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -115,6 +115,7 @@ CONFIG_SCHEMA = ( icon=ICON_COUNTER, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_MINIMUM_SIGNAL_QUALITY, default="MEDIUM"): cv.enum( SIGNAL_QUALITIES, upper=True diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 817f99375e..cb6b9d144f 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -69,9 +69,6 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): if CORE.is_esp8266 or CORE.is_libretiny: return ["async_tcp", "json"] - # ESP32 needs socket for wake_loop_threadsafe() - if CORE.is_esp32: - return ["json", "socket"] return ["json"] @@ -348,10 +345,7 @@ async def to_code(config): # https://github.com/heman/async-mqtt-client/blob/master/library.json cg.add_library("heman/AsyncMqttClient-esphome", "2.0.0") - # MQTT on ESP32 uses wake_loop_threadsafe() to wake the main loop from the MQTT event handler - # This enables low-latency MQTT event processing instead of waiting for select() timeout if CORE.is_esp32: - socket.require_wake_loop_threadsafe() # Re-enable ESP-IDF's mqtt component (excluded by default to save compile time) # IDF 6.0 moved esp-mqtt to an external component if idf_version() >= cv.Version(6, 0, 0): @@ -510,7 +504,7 @@ async def mqtt_publish_action_to_code(config, action_id, template_arg, args): cg.add(var.set_payload(template_)) template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, bool) + template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) cg.add(var.set_retain(template_)) return var @@ -543,7 +537,7 @@ async def mqtt_publish_json_action_to_code(config, action_id, template_arg, args cg.add(var.set_payload(lambda_)) template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, bool) + template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) cg.add(var.set_retain(template_)) return var diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index ab067c4418..499a330730 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -202,10 +202,8 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b // allocate() returned non-null, the queue cannot be full. instance->mqtt_event_queue_.push(event); - // Wake main loop immediately to process MQTT event instead of waiting for select() timeout -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + // Wake main loop immediately to process MQTT event App.wake_loop_threadsafe(); -#endif } } 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/neopixelbus/_methods.py b/esphome/components/neopixelbus/_methods.py index 9072f78035..e1c327a2e0 100644 --- a/esphome/components/neopixelbus/_methods.py +++ b/esphome/components/neopixelbus/_methods.py @@ -344,7 +344,7 @@ def _spi_extra_validate(config): if CORE.is_esp32: return - if config[CONF_DATA_PIN] != 13 and config[CONF_CLOCK_PIN] != 14: + if config[CONF_DATA_PIN] != 13 or config[CONF_CLOCK_PIN] != 14: raise cv.Invalid( "SPI only supports pins GPIO13 for data and GPIO14 for clock on ESP8266" ) 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/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 5b5922887c..29f5bdaea7 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -76,13 +76,13 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, bool) + template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) cg.add(var.set_publish_state(template_)) - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, bool) + template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) cg.add(var.set_send_to_nextion(template_)) return var diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 506eb1202b..dc3d5c6d09 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({}), @@ -138,12 +148,62 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, float) + template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.float_) cg.add(var.set_brightness(template_)) 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 ab268fed7f..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 { @@ -150,10 +153,12 @@ void Nextion::reset_(bool reset_nextion) { delete entry; // NOLINT(cppcoreguidelines-owning-memory) } this->nextion_queue_.clear(); +#ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { delete entry; // NOLINT(cppcoreguidelines-owning-memory) } this->waveform_queue_.clear(); +#endif // USE_NEXTION_WAVEFORM } void Nextion::dump_config() { @@ -496,20 +501,21 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid baud rate"); break; case 0x12: // invalid Waveform ID or Channel # was used +#ifdef USE_NEXTION_WAVEFORM if (this->waveform_queue_.empty()) { ESP_LOGW(TAG, "Waveform ID/ch used but no sensor queued"); } else { auto &nb = this->waveform_queue_.front(); NextionComponentBase *component = nb->component; - ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); } +#else // USE_NEXTION_WAVEFORM + ESP_LOGW(TAG, "Waveform ID/ch error but waveform not enabled"); +#endif // USE_NEXTION_WAVEFORM break; case 0x1A: // variable name invalid ESP_LOGW(TAG, "Invalid variable name"); @@ -703,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; } @@ -712,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); } @@ -729,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; } @@ -741,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); } @@ -762,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; } @@ -778,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); } @@ -795,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; } @@ -805,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); } @@ -812,29 +835,30 @@ void Nextion::process_nextion_commands_() { } case 0xFD: { // data transparent transmit finished ESP_LOGVV(TAG, "Data transmit done"); +#ifdef USE_NEXTION_WAVEFORM this->check_pending_waveform_(); +#endif // USE_NEXTION_WAVEFORM break; } case 0xFE: { // data transparent transmit ready ESP_LOGVV(TAG, "Ready for transmit"); +#ifdef USE_NEXTION_WAVEFORM if (this->waveform_queue_.empty()) { ESP_LOGE(TAG, "No waveforms queued"); break; } - auto &nb = this->waveform_queue_.front(); auto *component = nb->component; - size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() - : 255; // ADDT command can only send 255 - + size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; this->write_array(component->get_wave_buffer().data(), static_cast(buffer_to_send)); - ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); - component->clear_wave_buffer(buffer_to_send); delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); +#else // USE_NEXTION_WAVEFORM + ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); +#endif // USE_NEXTION_WAVEFORM break; } default: @@ -934,8 +958,13 @@ void Nextion::all_components_send_state_(bool force_update) { binarysensortype->send_state_to_nextion(); } for (auto *sensortype : this->sensortype_) { - if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == 0) +#ifdef USE_NEXTION_WAVEFORM + if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == UINT8_MAX) { +#else // USE_NEXTION_WAVEFORM + if (force_update || sensortype->get_needs_to_send_update()) { +#endif // USE_NEXTION_WAVEFORM sensortype->send_state_to_nextion(); + } } for (auto *switchtype : this->switchtype_) { if (force_update || switchtype->get_needs_to_send_update()) @@ -1239,13 +1268,11 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { } } +#ifdef USE_NEXTION_WAVEFORM /** - * @brief Add addt command to the queue + * @brief Add addt command to the waveform queue. * - * @param component_id The waveform component id - * @param wave_chan_id The waveform channel to send it to - * @param buffer_to_send The buffer size - * @param buffer_size The buffer data + * @param component Pointer to the Nextion component with waveform data to send. */ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping()) @@ -1262,7 +1289,11 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); - this->waveform_queue_.push_back(nextion_queue); + if (!this->waveform_queue_.push(nextion_queue)) { + ESP_LOGW(TAG, "Waveform queue full, drop"); + delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + return; + } if (this->waveform_queue_.size() == 1) this->check_pending_waveform_(); } @@ -1273,17 +1304,17 @@ void Nextion::check_pending_waveform_() { auto *nb = this->waveform_queue_.front(); auto *component = nb->component; - size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() - : 255; // ADDT command can only send 255 + size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); if (!this->send_command_(command)) { delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); } } +#endif // USE_NEXTION_WAVEFORM void Nextion::set_writer(const nextion_writer_t &writer) { this->writer_ = writer; } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index b3ecbf46b1..c84a5cd49c 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -7,8 +7,13 @@ #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 +#include "esphome/core/helpers.h" +#endif // USE_NEXTION_WAVEFORM + #include "nextion_base.h" #include "nextion_component.h" @@ -602,6 +607,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ void disable_component_touch(const char *component); +#ifdef USE_NEXTION_WAVEFORM /** * Add waveform data to a waveform component * @param component_id The integer component id. @@ -611,6 +617,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value); void open_waveform_channel(uint8_t component_id, uint8_t channel_number, uint8_t value); +#endif // USE_NEXTION_WAVEFORM /** * Display a picture at coordinates. @@ -1177,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(); /** @@ -1205,7 +1265,9 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void add_to_get_queue(NextionComponentBase *component) override; +#ifdef USE_NEXTION_WAVEFORM void add_addt_command_to_queue(NextionComponentBase *component) override; +#endif // USE_NEXTION_WAVEFORM void update_components_by_prefix(const std::string &prefix); @@ -1391,7 +1453,11 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe #endif // USE_NEXTION_COMMAND_SPACING std::list nextion_queue_; - std::list waveform_queue_; +#ifdef USE_NEXTION_WAVEFORM + /// Fixed-size ring buffer for waveform queue. Nextion supports at most 4 waveform + /// channels (IDs 0-3), so 4 entries is both the correct maximum and a safe default. + StaticRingBuffer waveform_queue_; +#endif // USE_NEXTION_WAVEFORM uint16_t recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag); void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; @@ -1460,7 +1526,9 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe const std::string &variable_name_to_send, const std::string &state_value, bool is_sleep_safe = false); +#ifdef USE_NEXTION_WAVEFORM void check_pending_waveform_(); +#endif // USE_NEXTION_WAVEFORM #ifdef USE_NEXTION_TFT_UPLOAD #ifdef USE_ESP8266 @@ -1521,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/nextion/nextion_base.h b/esphome/components/nextion/nextion_base.h index 2c516fc80f..4a2dc90d40 100644 --- a/esphome/components/nextion/nextion_base.h +++ b/esphome/components/nextion/nextion_base.h @@ -33,7 +33,9 @@ class NextionBase { const std::string &variable_name_to_send, const std::string &state_value) = 0; +#ifdef USE_NEXTION_WAVEFORM virtual void add_addt_command_to_queue(NextionComponentBase *component) = 0; +#endif // USE_NEXTION_WAVEFORM virtual void add_to_get_queue(NextionComponentBase *component) = 0; diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a7e65b5ddf..a332d342ee 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -217,6 +217,7 @@ void Nextion::set_component_value(const char *component, int32_t value) { this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } +#ifdef USE_NEXTION_WAVEFORM void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); @@ -226,6 +227,7 @@ void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); } +#endif // USE_NEXTION_WAVEFORM void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index c1d0ae8ed1..6676d01920 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -64,6 +64,7 @@ class NextionComponentBase { uint8_t get_component_id() const { return this->component_id_; } void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } +#ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } @@ -76,6 +77,7 @@ class NextionComponentBase { this->wave_buffer_.erase(this->wave_buffer_.begin(), this->wave_buffer_.begin() + buffer_sent); } } +#endif // USE_NEXTION_WAVEFORM const std::string &get_variable_name() const { return this->variable_name_; } const std::string &get_variable_name_to_send() const { return this->variable_name_to_send_; } @@ -85,19 +87,23 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } +#ifdef USE_NEXTION_WAVEFORM // Remove before 2026.10.0 ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } +#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; std::string variable_name_to_send_; uint8_t component_id_ = 0; +#ifdef USE_NEXTION_WAVEFORM uint8_t wave_chan_id_ = UINT8_MAX; std::vector wave_buffer_; int wave_max_length_ = 255; +#endif // USE_NEXTION_WAVEFORM bool needs_to_send_update_; }; diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index cab531f1db..61cb42e62c 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -85,16 +85,16 @@ async def to_code(config): cg.add(var.set_component_id(config[CONF_COMPONENT_ID])) if CONF_WAVE_CHANNEL_ID in config: + cg.add_define("USE_NEXTION_WAVEFORM") cg.add(var.set_wave_channel_id(config[CONF_WAVE_CHANNEL_ID])) - - if CONF_WAVEFORM_SEND_LAST_VALUE in config: - cg.add(var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE])) - - if CONF_WAVE_MAX_VALUE in config: - cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) - - if CONF_WAVE_MAX_LENGTH in config: - cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) + if CONF_WAVEFORM_SEND_LAST_VALUE in config: + cg.add( + var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE]) + ) + if CONF_WAVE_MAX_VALUE in config: + cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) + if CONF_WAVE_MAX_LENGTH in config: + cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) @automation.register_action( @@ -116,13 +116,13 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, float) + template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_state(template_)) - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, bool) + template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) cg.add(var.set_publish_state(template_)) - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, bool) + template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) cg.add(var.set_send_to_nextion(template_)) return var diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index d4fad86286..ca657522f9 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -10,37 +10,44 @@ void NextionSensor::process_sensor(const std::string &variable_name, int state) if (!this->nextion_->is_setup()) return; - if (this->wave_chan_id_ == UINT8_MAX && this->variable_name_ == variable_name) { +#ifdef USE_NEXTION_WAVEFORM + if (this->wave_chan_id_ == UINT8_MAX && this->variable_name_ == variable_name) +#else // USE_NEXTION_WAVEFORM + if (this->variable_name_ == variable_name) +#endif // USE_NEXTION_WAVEFORM + { this->publish_state(state); ESP_LOGD(TAG, "Sensor: %s=%d", variable_name.c_str(), state); } } +#ifdef USE_NEXTION_WAVEFORM void NextionSensor::add_to_wave_buffer(float state) { this->needs_to_send_update_ = true; - int wave_state = (int) ((state / (float) this->wave_maxvalue_) * 100); - - wave_buffer_.push_back(wave_state); - + this->wave_buffer_.push_back(wave_state); if (this->wave_buffer_.size() > (size_t) this->wave_max_length_) { this->wave_buffer_.erase(this->wave_buffer_.begin()); } } +#endif // USE_NEXTION_WAVEFORM void NextionSensor::update() { if (!this->nextion_->is_setup() || this->nextion_->is_updating()) return; +#ifdef USE_NEXTION_WAVEFORM if (this->wave_chan_id_ == UINT8_MAX) { this->nextion_->add_to_get_queue(this); } else { if (this->send_last_value_) { this->add_to_wave_buffer(this->last_value_); } - this->wave_update_(); } +#else // USE_NEXTION_WAVEFORM + this->nextion_->add_to_get_queue(this); +#endif // USE_NEXTION_WAVEFORM } void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { @@ -50,61 +57,60 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { if (std::isnan(state)) return; - if (this->wave_chan_id_ == UINT8_MAX) { - if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { - this->needs_to_send_update_ = true; - } else { - this->needs_to_send_update_ = false; - - if (this->precision_ > 0) { - double to_multiply = pow(10, this->precision_); - int state_value = (int) (state * to_multiply); - - this->nextion_->add_no_result_to_queue_with_set(this, (int) state_value); - } else { - this->nextion_->add_no_result_to_queue_with_set(this, (int) state); - } - } - } - } else { +#ifdef USE_NEXTION_WAVEFORM + if (this->wave_chan_id_ != UINT8_MAX) { + // Waveform sensor — buffer the value, don't send directly. if (this->send_last_value_) { this->last_value_ = state; // Update will handle setting the buffer } else { this->add_to_wave_buffer(state); } + this->update_component_settings(); + return; + } +#endif // USE_NEXTION_WAVEFORM + + if (send_to_nextion) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { + this->needs_to_send_update_ = true; + } else { + this->needs_to_send_update_ = false; + if (this->precision_ > 0) { + double to_multiply = pow(10, this->precision_); + int state_value = (int) (state * to_multiply); + this->nextion_->add_no_result_to_queue_with_set(this, (int) state_value); + } else { + this->nextion_->add_no_result_to_queue_with_set(this, (int) state); + } + } } float published_state = state; - if (this->wave_chan_id_ == UINT8_MAX) { - if (publish) { - if (this->precision_ > 0) { - double to_multiply = pow(10, -this->precision_); - published_state = (float) (state * to_multiply); - } - - this->publish_state(published_state); + if (publish) { + if (this->precision_ > 0) { + double to_multiply = pow(10, -this->precision_); + published_state = (float) (state * to_multiply); } + this->publish_state(published_state); } this->update_component_settings(); ESP_LOGN(TAG, "Write: %s=%lf", this->variable_name_.c_str(), published_state); } +#ifdef USE_NEXTION_WAVEFORM void NextionSensor::wave_update_() { if (this->nextion_->is_sleeping() || this->wave_buffer_.empty()) { return; } - #ifdef NEXTION_PROTOCOL_LOG size_t buffer_to_send = this->wave_buffer_.size() < 255 ? this->wave_buffer_.size() : 255; // ADDT command can only send 255 - ESP_LOGN(TAG, "Wave update: %zu/%zu vals to comp %d ch %d", buffer_to_send, this->wave_buffer_.size(), this->component_id_, this->wave_chan_id_); -#endif - +#endif // NEXTION_PROTOCOL_LOG this->nextion_->add_addt_command_to_queue(this); } +#endif // USE_NEXTION_WAVEFORM } // namespace esphome::nextion diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index f1a3ff72ec..72e3982b3a 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -15,22 +15,30 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void update_component() override { this->update(); } void update() override; - void add_to_wave_buffer(float state); void set_precision(uint8_t precision) { this->precision_ = precision; } void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } - void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } - void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void process_sensor(const std::string &variable_name, int state) override; void set_state(float state) override { this->set_state(state, true, true); } void set_state(float state, bool publish) override { this->set_state(state, publish, true); } void set_state(float state, bool publish, bool send_to_nextion) override; + NextionQueueType get_queue_type() const override { +#ifdef USE_NEXTION_WAVEFORM + return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; +#else // USE_NEXTION_WAVEFORM + return NextionQueueType::SENSOR; +#endif // USE_NEXTION_WAVEFORM + } + +#ifdef USE_NEXTION_WAVEFORM + void add_to_wave_buffer(float state); + void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void set_waveform_send_last_value(bool send_last_value) { this->send_last_value_ = send_last_value; } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } - NextionQueueType get_queue_type() const override { - return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; - } +#endif // USE_NEXTION_WAVEFORM + void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value, publish, send_to_nextion); @@ -38,10 +46,11 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol protected: uint8_t precision_ = 0; +#ifdef USE_NEXTION_WAVEFORM uint32_t wave_maxvalue_ = 255; - float last_value_ = 0; bool send_last_value_ = true; void wave_update_(); +#endif // USE_NEXTION_WAVEFORM }; } // namespace esphome::nextion diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 81e6721d0f..29749ecab0 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -58,13 +58,13 @@ async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, bool) + template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) cg.add(var.set_publish_state(template_)) - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, bool) + template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) cg.add(var.set_send_to_nextion(template_)) return var diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5054e5e0df..5d92a4fa80 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -46,6 +46,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -145,7 +146,9 @@ CONFIG_SCHEMA = cv.All( set_core_data, cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): cv.Schema( { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 90f9fe1835..f13ccc4c36 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,6 +79,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, setup_device_class, @@ -186,7 +187,11 @@ NUMBER_OPERATION_OPTIONS = { } validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") -validate_unit_of_measurement = cv.string_strict +validate_unit_of_measurement = cv.All( + cv.string_strict, + # Keep in sync with max_data_length in api.proto + cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), +) _NUMBER_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) @@ -238,20 +243,28 @@ 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) if CONF_ABOVE in conf: - template_ = await cg.templatable(conf[CONF_ABOVE], [(float, "x")], float) + template_ = await cg.templatable( + conf[CONF_ABOVE], [(float, "x")], cg.float_ + ) cg.add(trigger.set_min(template_)) if CONF_BELOW in conf: - template_ = await cg.templatable(conf[CONF_BELOW], [(float, "x")], float) + template_ = await cg.templatable( + conf[CONF_BELOW], [(float, "x")], cg.float_ + ) cg.add(trigger.set_max(template_)) await automation.build_automation(trigger, [(float, "x")], conf) @@ -353,7 +366,7 @@ OPERATION_BASE_SCHEMA = cv.Schema( async def number_set_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) cg.add(var.set_value(template_)) return var @@ -436,10 +449,14 @@ async def number_to_to_code(config, action_id, template_arg, args): to_ = await cg.templatable(operation, args, NumberOperation) cg.add(var.set_operation(to_)) if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, bool) + template_ = await cg.templatable(cycle, args, cg.bool_) cg.add(var.set_cycle(template_)) if (mode := config.get(CONF_MODE)) is not None: - cg.add(var.set_operation(NUMBER_OPERATION_OPTIONS[mode])) + template_ = await cg.templatable( + NUMBER_OPERATION_OPTIONS[mode], args, NumberOperation + ) + cg.add(var.set_operation(template_)) if (cycle := config.get(CONF_CYCLE)) is not None: - cg.add(var.set_cycle(cycle)) + template_ = await cg.templatable(cycle, args, cg.bool_) + cg.add(var.set_cycle(template_)) return var diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index a7cd04f083..2843aa6bf5 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -63,8 +63,8 @@ class ValueRangeTrigger : public Trigger, public Component { Number *parent_; ESPPreferenceObject rtc_; bool previous_in_range_{false}; - TemplatableValue min_{NAN}; - TemplatableValue max_{NAN}; + TemplatableFn min_{[](float) -> float { return NAN; }}; + TemplatableFn max_{[](float) -> float { return NAN; }}; }; template class NumberInRangeCondition : public Condition { diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 5b8294c70e..ee4d5abb1c 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -100,11 +100,19 @@ async def online_image_action_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) cg.add(var.set_url(template_)) if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, bool) + template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) cg.add(var.set_update(template_)) 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/opentherm/input.py b/esphome/components/opentherm/input.py index c5814f74e2..e711e4df8e 100644 --- a/esphome/components/opentherm/input.py +++ b/esphome/components/opentherm/input.py @@ -2,51 +2,48 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv +from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE from . import generate, schema -CONF_min_value = "min_value" -CONF_max_value = "max_value" -CONF_auto_min_value = "auto_min_value" -CONF_auto_max_value = "auto_max_value" -CONF_step = "step" +CONF_AUTO_MIN_VALUE = "auto_min_value" +CONF_AUTO_MAX_VALUE = "auto_max_value" OpenthermInput = generate.opentherm_ns.class_("OpenthermInput") def validate_min_value_less_than_max_value(conf): if ( - CONF_min_value in conf - and CONF_max_value in conf - and conf[CONF_min_value] > conf[CONF_max_value] + CONF_MIN_VALUE in conf + and CONF_MAX_VALUE in conf + and conf[CONF_MIN_VALUE] > conf[CONF_MAX_VALUE] ): - raise cv.Invalid(f"{CONF_min_value} must be less than {CONF_max_value}") + raise cv.Invalid(f"{CONF_MIN_VALUE} must be less than {CONF_MAX_VALUE}") return conf def input_schema(entity: schema.InputSchema) -> cv.Schema: result = cv.Schema( { - cv.Optional(CONF_min_value, entity.range[0]): cv.float_range( + cv.Optional(CONF_MIN_VALUE, entity.range[0]): cv.float_range( entity.range[0], entity.range[1] ), - cv.Optional(CONF_max_value, entity.range[1]): cv.float_range( + cv.Optional(CONF_MAX_VALUE, entity.range[1]): cv.float_range( entity.range[0], entity.range[1] ), } ) result = result.add_extra(validate_min_value_less_than_max_value) - result = result.extend({cv.Optional(CONF_step, False): cv.float_}) if entity.auto_min_value is not None: - result = result.extend({cv.Optional(CONF_auto_min_value, False): cv.boolean}) + result = result.extend({cv.Optional(CONF_AUTO_MIN_VALUE, False): cv.boolean}) if entity.auto_max_value is not None: - result = result.extend({cv.Optional(CONF_auto_max_value, False): cv.boolean}) + result = result.extend({cv.Optional(CONF_AUTO_MAX_VALUE, False): cv.boolean}) return result def generate_setters(entity: cg.MockObj, conf: dict[str, Any]) -> None: - generate.add_property_set(entity, CONF_min_value, conf) - generate.add_property_set(entity, CONF_max_value, conf) - generate.add_property_set(entity, CONF_auto_min_value, conf) - generate.add_property_set(entity, CONF_auto_max_value, conf) + generate.add_property_set(entity, CONF_MIN_VALUE, conf) + generate.add_property_set(entity, CONF_MAX_VALUE, conf) + generate.add_property_set(entity, CONF_AUTO_MIN_VALUE, conf) + generate.add_property_set(entity, CONF_AUTO_MAX_VALUE, conf) diff --git a/esphome/components/opentherm/number/__init__.py b/esphome/components/opentherm/number/__init__.py index 6dbc45f49b..17ec12a61a 100644 --- a/esphome/components/opentherm/number/__init__.py +++ b/esphome/components/opentherm/number/__init__.py @@ -3,7 +3,13 @@ from typing import Any import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv -from esphome.const import CONF_INITIAL_VALUE, CONF_RESTORE_VALUE, CONF_STEP +from esphome.const import ( + CONF_INITIAL_VALUE, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_RESTORE_VALUE, + CONF_STEP, +) from .. import const, generate, input, schema, validate @@ -18,9 +24,9 @@ OpenthermNumber = generate.opentherm_ns.class_( async def new_openthermnumber(config: dict[str, Any]) -> cg.Pvariable: var = await number.new_number( config, - min_value=config[input.CONF_min_value], - max_value=config[input.CONF_max_value], - step=config[input.CONF_step], + min_value=config[CONF_MIN_VALUE], + max_value=config[CONF_MAX_VALUE], + step=config[CONF_STEP], ) await cg.register_component(var, config) input.generate_setters(var, config) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 7c9a308303..21dad4f867 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -181,7 +181,7 @@ void OpenThreadSrpComponent::setup() { memcpy(string, host_name.c_str(), host_name_len); // Set port - entry->mService.mPort = const_cast &>(service.port).value(); + entry->mService.mPort = service.port.value(); otDnsTxtEntry *txt_entries = reinterpret_cast(this->pool_alloc_(sizeof(otDnsTxtEntry) * service.txt_records.size())); diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index ddec8f264c..b672831bf0 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_IP_ADDRESS): text_sensor.text_sensor_schema( IPAddressOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), + ).extend(cv.polling_component_schema("1s")), cv.Optional(CONF_ROLE): text_sensor.text_sensor_schema( RoleOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), diff --git a/esphome/components/ota/automation.h b/esphome/components/ota/automation.h index 92c0050ba0..29a8878136 100644 --- a/esphome/components/ota/automation.h +++ b/esphome/components/ota/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace ota { +namespace esphome::ota { class OTAStateChangeTrigger final : public Trigger, public OTAStateListener { public: @@ -67,6 +66,5 @@ class OTAErrorTrigger final : public Trigger, public OTAStateListener { OTAComponent *parent_; }; -} // namespace ota -} // namespace esphome +} // namespace esphome::ota #endif diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 01a18a58ef..17949de642 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,7 +1,6 @@ #include "ota_backend.h" -namespace esphome { -namespace ota { +namespace esphome::ota { #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -34,5 +33,4 @@ void OTAComponent::notify_state_(OTAState state, float progress, uint8_t error) } #endif -} // namespace ota -} // namespace esphome +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index ab0ec58e8a..bd9c481901 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -8,8 +8,7 @@ #include #endif -namespace esphome { -namespace ota { +namespace esphome::ota { enum OTAResponseTypes { OTA_RESPONSE_OK = 0x00, @@ -38,6 +37,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A, OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B, OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C, + OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -117,5 +117,4 @@ OTAGlobalCallback *get_global_ota_callback(); // - notify_state_deferred_() when in separate task (e.g., web_server OTA) // This ensures proper listener execution in all contexts. #endif -} // namespace ota -} // namespace esphome +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index d364f75007..dcd71e92dd 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace ota { +namespace esphome::ota { static const char *const TAG = "ota.arduino_libretiny"; @@ -66,7 +65,5 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 4514bf84bd..3d426e6759 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -4,8 +4,7 @@ #include "esphome/core/defines.h" -namespace esphome { -namespace ota { +namespace esphome::ota { class ArduinoLibreTinyOTABackend final { public: @@ -22,7 +21,5 @@ class ArduinoLibreTinyOTABackend final { std::unique_ptr make_ota_backend(); -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index e2a57ec665..bc8ef812e6 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace ota { +namespace esphome::ota { static const char *const TAG = "ota.arduino_rp2040"; @@ -75,8 +74,6 @@ void ArduinoRP2040OTABackend::abort() { rp2040::preferences_prevent_write(false); } -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_RP2040 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index 0956cb4b4b..05bd2f5cc4 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -6,8 +6,7 @@ #include "esphome/core/defines.h" #include "esphome/core/macros.h" -namespace esphome { -namespace ota { +namespace esphome::ota { class ArduinoRP2040OTABackend final { public: @@ -24,8 +23,6 @@ class ArduinoRP2040OTABackend final { std::unique_ptr make_ota_backend(); -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_RP2040 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 925bb39645..598fce1562 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -3,13 +3,15 @@ #include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" +#include "esphome/core/log.h" #include #include #include -namespace esphome { -namespace ota { +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; std::unique_ptr make_ota_backend() { return make_unique(); } @@ -99,7 +101,12 @@ OTAResponseTypes IDFOTABackend::end() { } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { +#ifdef USE_OTA_SIGNED_VERIFICATION + ESP_LOGE(TAG, "OTA validation failed (err=0x%X) - possible signature verification failure", err); + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; +#else return OTA_RESPONSE_ERROR_UPDATE_END; +#endif } if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; @@ -112,6 +119,5 @@ void IDFOTABackend::abort() { this->update_handle_ = 0; } -} // namespace ota -} // namespace esphome +} // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index a0f538afc0..d007bcd128 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace ota { +namespace esphome::ota { class IDFOTABackend final { public: @@ -29,6 +28,5 @@ class IDFOTABackend final { std::unique_ptr make_ota_backend(); -} // namespace ota -} // namespace esphome +} // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index a4ce2b2d1a..36798f2d7f 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -104,7 +104,7 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): async def output_set_level_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, float) + template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) cg.add(var.set_level(template_)) return var @@ -123,7 +123,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): async def output_set_min_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MIN_POWER], args, float) + template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) cg.add(var.set_min_power(template_)) return var @@ -142,7 +142,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): async def output_set_max_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MAX_POWER], args, float) + template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) cg.add(var.set_max_power(template_)) return var diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 1a6df84fe0..04db690c6f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -6,7 +6,12 @@ from pathlib import Path from typing import Any from esphome import git, yaml_util -from esphome.components.substitutions import ContextVars, push_context, substitute +from esphome.components.substitutions import ( + ContextVars, + push_context, + resolve_include, + substitute, +) from esphome.components.substitutions.jinja import has_jinja from esphome.config_helpers import Remove, merge_config import esphome.config_validation as cv @@ -31,6 +36,8 @@ from esphome.core import EsphomeError _LOGGER = logging.getLogger(__name__) DOMAIN = CONF_PACKAGES +# Guard against infinite include chains (e.g. A includes B includes A). +MAX_INCLUDE_DEPTH = 20 def is_remote_package(package_config: dict) -> bool: @@ -59,8 +66,8 @@ def valid_package_contents(package_config: dict) -> dict: for k, v in package_config.items(): if not isinstance(k, str): raise cv.Invalid("Package content keys must be strings") - if isinstance(v, (dict, list, Remove)): - continue # e.g. script: [], psram: !remove, logger: {level: debug} + if isinstance(v, (dict, list, Remove, yaml_util.IncludeFile)): + continue # e.g. script: [], psram: !remove, logger: {level: debug}, switch: !include switches.yaml if v is None: continue # e.g. web_server: if isinstance(v, str) and has_jinja(v): @@ -160,6 +167,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All( PACKAGE_SCHEMA = cv.Any( # A package definition is either: validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or + yaml_util.IncludeFile, # isinstance check — passes IncludeFile objects through unchanged, or: valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} # which will have to be fully validated later as per each component's schema. ) @@ -396,16 +404,49 @@ class _PackageProcessor: self.skip_update = skip_update def resolve_package( - self, package_config: dict | str, context_vars: ContextVars | None + self, + package_config: dict | str | yaml_util.IncludeFile, + context_vars: ContextVars | None, ) -> dict: - """Substitute variables in the definition and fetch remote packages. + """Resolve a package definition to a concrete ``dict`` and fetch remote packages. - The input may be a ``str`` (git shorthand or Jinja expression) or a - ``dict`` (remote or local package). After ``PACKAGE_SCHEMA`` validation - the result is always a ``dict``. + The input may be a ``str`` (git shorthand or Jinja expression), a + ``dict`` (remote or local package), or an ``IncludeFile`` whose filename + may itself contain substitution expressions. + + The loop handles the case where loading an ``IncludeFile`` yields another + ``IncludeFile`` (e.g. a chain of deferred includes). Each iteration: + + 1. If the current value is an ``IncludeFile``, load it — resolving any + substitutions in its filename first. + 2. Substitute variables in the resulting value (for strings and remote + package dicts). + 3. Validate against ``PACKAGE_SCHEMA``. If the result is a ``dict``, + the loop exits; otherwise another iteration is needed. + + Raises ``cv.Invalid`` if the chain has not resolved to a ``dict`` after + ``MAX_INCLUDE_DEPTH`` iterations. """ - package_config = _substitute_package_definition(package_config, context_vars) - package_config = PACKAGE_SCHEMA(package_config) + for _ in range(MAX_INCLUDE_DEPTH): + if isinstance(package_config, yaml_util.IncludeFile): + package_config, _ = resolve_include( + package_config, + [], + context_vars or ContextVars(), + strict_undefined=False, + ) + + package_config = _substitute_package_definition( + package_config, context_vars + ) + package_config = PACKAGE_SCHEMA(package_config) + if isinstance(package_config, dict): + break + else: + raise cv.Invalid( + f"Maximum include nesting depth ({MAX_INCLUDE_DEPTH}) exceeded" + ) + if is_remote_package(package_config): package_config = _process_remote_package(package_config, self.skip_update) return package_config 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/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 626b08a378..99b812b33b 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -29,6 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -43,6 +45,8 @@ async def to_code(config): cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index adc7bc0fb5..ac4f119dfe 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -34,12 +34,26 @@ void PCA9554Component::setup() { this->read_inputs_(); ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); -} + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9554Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read — only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); +} +void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_loop_soon_any_context(); } void PCA9554Component::loop() { - // Invalidate the cache at the start of each loop. - // The actual read will happen on demand when digital_read() is called + // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + // Interrupt-driven: disable loop until next interrupt fires + this->disable_loop(); + } } void PCA9554Component::dump_config() { @@ -47,6 +61,7 @@ void PCA9554Component::dump_config() { "PCA9554:\n" " I/O Pins: %d", this->pin_count_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -76,6 +91,11 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { // Clear mode mask bit this->config_mask_ &= ~(1 << pin); + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Set mode mask bit this->config_mask_ |= 1 << pin; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 1d877f9ce2..f33f9d4592 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -16,7 +16,6 @@ class PCA9554Component : public Component, /// Check i2c availability and setup masks void setup() override; - /// Invalidate cache at start of each loop void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -26,8 +25,11 @@ class PCA9554Component : public Component, void dump_config() override; void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: + static void IRAM_ATTR gpio_intr(PCA9554Component *arg); + bool read_inputs_(); bool write_register_(uint8_t reg, uint16_t value); @@ -48,6 +50,7 @@ class PCA9554Component : public Component, uint16_t input_mask_{0x00}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index 9d993c2105..2f6dcc56ed 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -27,7 +27,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_CS_PIN): pins.gpio_output_pin_schema, # CE - cv.Optional(CONF_CONTRAST, default=0x7F): cv.int_, + cv.Optional(CONF_CONTRAST, default=0x7F): cv.int_range(min=0, max=127), } ) .extend(cv.polling_component_schema("1s")) 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 f387d0a610..d8a1e20db6 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -27,6 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -39,6 +41,8 @@ async def to_code(config): await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): @@ -51,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/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index d3ec31436d..bf4a9442a2 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -15,16 +15,33 @@ void PCF8574Component::setup() { this->write_gpio_(); this->read_gpio_(); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCF8574Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read — only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); } +void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_loop_soon_any_context(); } void PCF8574Component::loop() { - // Invalidate the cache at the start of each loop + // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + // Interrupt-driven: disable loop until next interrupt fires + this->disable_loop(); + } } void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" " Is PCF8575: %s", YESNO(this->pcf8575_)); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -51,6 +68,11 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { this->mode_mask_ &= ~(1 << pin); // Write GPIO to enable input mode this->write_gpio_(); + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index b039173789..cae2e930b7 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -17,10 +17,10 @@ class PCF8574Component : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks void setup() override; - /// Invalidate cache at start of each loop void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -30,6 +30,8 @@ class PCF8574Component : public Component, void dump_config() override; protected: + static void IRAM_ATTR gpio_intr(PCF8574Component *arg); + bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -44,6 +46,7 @@ class PCF8574Component : public Component, /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index c64f923823..d5b19dab1c 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -33,6 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -46,6 +48,8 @@ async def to_code(config): await i2c.register_i2c_device(var, config) cg.add(var.set_reset(config[CONF_RESET])) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 9247e114f0..6e8631022a 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -33,9 +33,24 @@ void PI4IOE5V6408Component::setup() { return; } } + + // No need to clear latched interrupts before attaching the ISR — if INT is + // already low the ISR fires immediately, loop runs, cache invalidates, and + // the read clears the latch. One harmless extra read at most. + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PI4IOE5V6408Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + // Disable loop until an input pin is configured via pin_mode() + // For interrupt-driven mode, loop is re-enabled by the ISR + // For polling mode, loop is re-enabled when pin_mode() registers an input pin + this->disable_loop(); } +void IRAM_ATTR PI4IOE5V6408Component::gpio_intr(PI4IOE5V6408Component *arg) { arg->enable_loop_soon_any_context(); } void PI4IOE5V6408Component::dump_config() { ESP_LOGCONFIG(TAG, "PI4IOE5V6408:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -55,12 +70,22 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { this->pull_up_down_mask_ &= ~(1 << pin); this->pull_enable_mask_ |= 1 << pin; } + // Enable polling loop for input pins (not needed for interrupt-driven mode + // where the ISR handles re-enabling loop) + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } // Write GPIO to enable input mode this->write_gpio_modes_(); } -void PI4IOE5V6408Component::loop() { this->reset_pin_cache_(); } +void PI4IOE5V6408Component::loop() { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } +} bool PI4IOE5V6408Component::read_gpio_outputs_() { if (this->is_failed()) @@ -142,6 +167,13 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { this->status_set_warning(LOG_STR("Failed to write GPIO pull enable")); return false; } + // Enable interrupts for input pins when interrupt pin is configured + // (input pins have mode_mask_ bit cleared) + if (this->interrupt_pin_ != nullptr && + !this->write_byte(PI4IOE5V6408_REGISTER_INTERRUPT_ENABLE_MASK, static_cast(~this->mode_mask_))) { + this->status_set_warning(LOG_STR("Failed to write interrupt enable mask")); + return false; + } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Wrote GPIO config:\n" diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 4dc31201ce..ff2474fe99 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -22,8 +22,11 @@ class PI4IOE5V6408Component : public Component, /// Indicate if the component should reset the state during setup void set_reset(bool reset) { this->reset_ = reset; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: + static void IRAM_ATTR gpio_intr(PI4IOE5V6408Component *arg); + bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -40,6 +43,7 @@ class PI4IOE5V6408Component : public Component, uint8_t pull_up_down_mask_{0x00}; bool reset_{true}; + InternalGPIOPin *interrupt_pin_{nullptr}; bool read_gpio_modes_(); bool write_gpio_modes_(); diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 18e33b8039..3e4ff754c9 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -189,13 +189,13 @@ async def set_control_parameters(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - kp_template_ = await cg.templatable(config[CONF_KP], args, float) + kp_template_ = await cg.templatable(config[CONF_KP], args, cg.float_) cg.add(var.set_kp(kp_template_)) - ki_template_ = await cg.templatable(config[CONF_KI], args, float) + ki_template_ = await cg.templatable(config[CONF_KI], args, cg.float_) cg.add(var.set_ki(ki_template_)) - kd_template_ = await cg.templatable(config[CONF_KD], args, float) + kd_template_ = await cg.templatable(config[CONF_KD], args, cg.float_) cg.add(var.set_kd(kd_template_)) return var diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 4ae8d9d487..d1ea981589 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -103,6 +103,6 @@ async def to_code(config): async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) cg.add(var.set_level(template_)) return var diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 8d3ba10d62..88c6566d63 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -102,46 +102,56 @@ TYPES = { unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=1, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, accuracy_decimals=0, device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_RECHARGE_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_UNDER_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_BULK_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_FLOAT_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_TYPE: sensor.sensor_schema( accuracy_decimals=0, @@ -150,11 +160,13 @@ TYPES = { unit_of_measurement=UNIT_AMPERE, accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_CURRENT_MAX_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_INPUT_VOLTAGE_RANGE: sensor.sensor_schema( accuracy_decimals=0, @@ -294,6 +306,7 @@ TYPES = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_EEPROM_VERSION: sensor.sensor_schema( accuracy_decimals=0, diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index cdcedc85ac..0a11120bf0 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -185,7 +185,7 @@ def validate_update_interval(value): return value -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(PMSX003Component), @@ -290,7 +290,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + validate_pmsx003_sensors, ) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index bb40f3e499..c0bc54c5ba 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -137,6 +137,6 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) - address = await cg.templatable(config[CONF_ADDRESS], args, int) + address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) cg.add(var.set_new_address(address)) return var 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/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 26a9e70f7c..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -10,12 +10,14 @@ AUTO_LOAD = ["web_server_base"] prometheus_ns = cg.esphome_ns.namespace("prometheus") PrometheusHandler = prometheus_ns.class_("PrometheusHandler", cg.Component) -CUSTOMIZED_ENTITY = cv.Schema( - { - cv.Optional(CONF_ID): cv.string_strict, - cv.Optional(CONF_NAME): cv.string_strict, - }, - cv.has_at_least_one_key, +CUSTOMIZED_ENTITY = cv.All( + cv.Schema( + { + cv.Optional(CONF_ID): cv.string_strict, + cv.Optional(CONF_NAME): cv.string_strict, + }, + ), + cv.has_at_least_one_key(CONF_ID, CONF_NAME), ) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index c09d778eda..3326745846 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -160,6 +160,6 @@ async def to_code(config): async def set_total_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, int) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) cg.add(var.set_total_pulses(template_)) return var diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 499b7309c8..ab3dd2a249 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -110,6 +110,6 @@ async def to_code(config): async def set_total_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, int) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) cg.add(var.set_total_pulses(template_)) return var diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 716cc1001a..450f663274 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, UNIT_AMPERE, UNIT_CELSIUS, UNIT_PERCENT, @@ -18,7 +19,7 @@ from esphome.const import ( from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns -PylontechSensor = pylontech_ns.class_("PylontechSensor", cg.Component) +PylontechSensor = pylontech_ns.class_("PylontechSensor") CONF_COULOMB = "coulomb" CONF_TEMPERATURE_LOW = "temperature_low" @@ -32,46 +33,55 @@ TYPES: dict[str, cv.Schema] = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=3, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=3, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_TEMPERATURE: sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_TEMPERATURE_LOW: sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_TEMPERATURE_HIGH: sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_VOLTAGE_LOW: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=3, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_VOLTAGE_HIGH: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=3, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_COULOMB: sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0, device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_MOS_TEMPERATURE: sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, accuracy_decimals=1, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), } diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 8986adc26c..25e71606a4 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -6,7 +6,7 @@ namespace esphome { namespace pylontech { -class PylontechSensor : public PylontechListener, public Component { +class PylontechSensor : public PylontechListener { public: PylontechSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index 15741ea9d1..f68ca10374 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -5,7 +5,7 @@ from esphome.const import CONF_ID from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns -PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor", cg.Component) +PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor") CONF_BASE_STATE = "base_state" CONF_VOLTAGE_STATE = "voltage_state" diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index a685512ed5..27a3993b3e 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome { namespace pylontech { -class PylontechTextSensor : public PylontechListener, public Component { +class PylontechTextSensor : public PylontechListener { public: PylontechTextSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index fa1c3961d0..c134bc19c1 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, @@ -66,6 +67,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index b79e370a05..fe34381ad8 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -100,6 +100,7 @@ heading_schema = sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, icon=ICON_SCREEN_ROTATION, accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, ) temperature_schema = sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index 290a864335..aebbe2fcc8 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -1,3 +1,8 @@ CODEOWNERS = ["@clydebarrow"] CONF_DRAW_FROM_ORIGIN = "draw_from_origin" + +DEPRECATED_COMPONENT = """ +The 'qspi_dbi' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 48d1f6d12e..48cd72ecdf 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -29,6 +31,7 @@ from . import CONF_DRAW_FROM_ORIGIN from .models import DriverChip DEPENDENCIES = ["spi"] +LOGGER = logging.getLogger(__name__) qspi_dbi_ns = cg.esphome_ns.namespace("qspi_dbi") QSPI_DBI = qspi_dbi_ns.class_( @@ -154,11 +157,15 @@ CONFIG_SCHEMA = cv.All( upper=True, key=CONF_MODEL, ), + _validate, cv.only_on_esp32, ) async def to_code(config): + LOGGER.warning( + "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a0594d7f67..cbf82e6f44 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -470,7 +470,7 @@ CANALSATLD_SCHEMA = cv.Schema( ) -@register_binary_sensor("canalsatld", CanalSatLDBinarySensor, CANALSAT_SCHEMA) +@register_binary_sensor("canalsatld", CanalSatLDBinarySensor, CANALSATLD_SCHEMA) def canalsatld_binary_sensor(var, config): cg.add( var.set_data( @@ -868,7 +868,7 @@ async def keeloq_action(var, config, args): cg.add(var.set_encrypted(template_)) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_LEVEL], args, bool) + template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) cg.add(var.set_vlow(template_)) @@ -1130,7 +1130,7 @@ def sony_dumper(var, config): async def sony_action(var, config, args): template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint32) + template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) cg.add(var.set_nbits(template_)) @@ -1174,7 +1174,7 @@ def symphony_dumper(var, config): async def symphony_action(var, config, args): template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint32) + template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) cg.add(var.set_nbits(template_)) template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) cg.add(var.set_repeats(template_)) @@ -1188,7 +1188,7 @@ def validate_raw_alternating(value): this_negative = val < 0 if i != 0 and this_negative == last_negative: raise cv.Invalid( - f"Values must alternate between being positive and negative, please see index {i} and {i + 1}", + f"Values must alternate between being positive and negative, please see index {i - 1} and {i}", [i], ) last_negative = this_negative @@ -1580,7 +1580,7 @@ async def rc_switch_type_a_action(var, config, args): cg.add( var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.std_string)) ) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_binary_sensor( @@ -1605,7 +1605,7 @@ async def rc_switch_type_b_action(var, config, args): cg.add(var.set_protocol(proto)) cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_binary_sensor( @@ -1638,7 +1638,7 @@ async def rc_switch_type_c_action(var, config, args): ) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_binary_sensor( @@ -1663,7 +1663,7 @@ async def rc_switch_type_d_action(var, config, args): cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, bool))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) @register_trigger("rc_switch", RCSwitchTrigger, RCSwitchData) @@ -2105,12 +2105,12 @@ async def abbwelcome_action(var, config, args): ) cg.add( var.set_source_address( - await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint16) + await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint32) ) ) cg.add( var.set_destination_address( - await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint16) + await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint32) ) ) cg.add( @@ -2123,7 +2123,8 @@ async def abbwelcome_action(var, config, args): await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8) ) ) - cg.add(var.set_auto_message_id(CONF_MESSAGE_ID not in config)) + template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_) + cg.add(var.set_auto_message_id(template_)) if CONF_MESSAGE_ID in config: cg.add( var.set_message_id( @@ -2231,3 +2232,9 @@ async def Toto_action(var, config, args): cg.add(var.set_rc_code_2(template_)) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) cg.add(var.set_command(template_)) + # Set toto-specific defaults (only if user didn't configure repeat) + if CONF_REPEAT not in config: + template_ = await cg.templatable(3, args, cg.uint32) + cg.add(var.set_send_times(template_)) + template_ = await cg.templatable(36000, args, cg.uint32) + cg.add(var.set_send_wait(template_)) diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 6a635b0f7c..53d453f7e3 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -35,8 +35,6 @@ template class TotoAction : public RemoteTransmitterActionBaserc_code_1_.value(x...); data.rc_code_2 = this->rc_code_2_.value(x...); data.command = this->command_.value(x...); - this->set_send_times(this->send_times_.value_or(x..., 3)); - this->set_send_wait(this->send_wait_.value_or(x..., 36000)); TotoProtocol().encode(dst, data); } }; diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 89019e296e..1163fc86eb 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -128,7 +128,7 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( async def digital_write_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, bool) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.bool_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 4e4705a889..3134cf7646 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import audio, esp32, socket, speaker +from esphome.components import audio, esp32, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -77,9 +77,6 @@ FINAL_VALIDATE_SCHEMA = _validate_audio_compatibility async def to_code(config): - # Enable wake_loop_threadsafe for immediate command processing from other tasks - socket.require_wake_loop_threadsafe() - var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await speaker.register_speaker(var, config) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index b737a2d39a..3b50353ddc 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -245,11 +245,9 @@ void ResamplerSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { uint32_t event_bits = xEventGroupGetBits(this->event_group_); if (!(event_bits & command_bit)) { xEventGroupSetBits(this->event_group_, command_bit); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) if (wake_loop) { App.wake_loop_threadsafe(); } -#endif } } diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index c6eb1749c3..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( @@ -185,9 +189,9 @@ RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( async def rf_bridge_send_advanced_code_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_LENGTH], args, cg.uint16) + template_ = await cg.templatable(config[CONF_LENGTH], args, cg.uint8) cg.add(var.set_length(template_)) - template_ = await cg.templatable(config[CONF_PROTOCOL], args, cg.uint16) + template_ = await cg.templatable(config[CONF_PROTOCOL], args, cg.uint8) cg.add(var.set_protocol(template_)) template_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) cg.add(var.set_code(template_)) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index e64e44f7c1..21239863e4 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_RESTORE_MODE, CONF_VALUE, ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) @@ -49,7 +50,7 @@ def validate_min_max_value(config): max_val = config[CONF_MAX_VALUE] if min_val >= max_val: raise cv.Invalid( - f"Max value {max_val} must be smaller than min value {min_val}" + f"Max value {max_val} must be greater than min value {min_val}" ) return config @@ -60,6 +61,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_STEPS, icon=ICON_ROTATE_RIGHT, accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, ) .extend( { @@ -82,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) @@ -102,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( @@ -117,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_), } ), @@ -126,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, int) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 0bb1811069..e452780d41 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from . import boards @@ -168,7 +169,9 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_BOARD): cv.string_strict, + cv.Required(CONF_BOARD): cv.All( + cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) + ), cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All( cv.positive_time_period_milliseconds, 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/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 4ea488a6cd..ad37926954 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -47,6 +47,6 @@ async def to_code(config): async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, float) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) cg.add(var.set_frequency(template_)) return var diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index c58ce8a01e..8628f0e063 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -1 +1,6 @@ CODEOWNERS = ["@clydebarrow"] + +DEPRECATED_COMPONENT = """ +The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_rgb' component. +""" diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index e92eee7c0c..314852832c 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display @@ -38,6 +40,7 @@ from esphome.const import ( ) DEPENDENCIES = ["esp32"] +LOGGER = logging.getLogger(__name__) rpi_dpi_rgb_ns = cg.esphome_ns.namespace("rpi_dpi_rgb") RPI_DPI_RGB = rpi_dpi_rgb_ns.class_("RpiDpiRgb", display.Display, cg.Component) @@ -102,7 +105,7 @@ CONFIG_SCHEMA = cv.All( } ), ), - cv.Optional(CONF_COLOR_ORDER): cv.one_of( + cv.Optional(CONF_COLOR_ORDER, default="BGR"): cv.one_of( *COLOR_ORDERS.keys(), upper=True ), cv.Optional(CONF_INVERT_COLORS, default=False): cv.boolean, @@ -126,6 +129,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + LOGGER.warning( + "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) 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/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 2ebe67c3a5..fa42b53496 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -248,6 +248,9 @@ void RuntimeImage::release_buffer_() { this->height_ = 0; this->buffer_width_ = 0; this->buffer_height_ = 0; +#ifdef USE_LVGL + memset(&this->dsc_, 0, sizeof(this->dsc_)); +#endif } } 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/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 40fa03392b..bae5e42b9b 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -55,11 +55,13 @@ void SafeModeComponent::dump_config() { #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition(); if (last_invalid != nullptr) { - ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label); - ESP_LOGW(TAG, "The device reset before the boot was marked successful"); + ESP_LOGW(TAG, + "OTA rollback detected! Rolled back from partition '%s'\n" + " The device reset before the boot was marked successful", + last_invalid->label); if (esp_reset_reason() == ESP_RST_BROWNOUT) { - ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!"); - ESP_LOGW(TAG, "See https://esphome.io/guides/faq.html#brownout-detector-was-triggered"); + ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!\n" + " See https://esphome.io/guides/faq.html#brownout-detector-was-triggered"); } } #endif @@ -86,7 +88,8 @@ void SafeModeComponent::mark_successful() { } void SafeModeComponent::loop() { - if (!this->boot_successful_ && (millis() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) { + if (!this->boot_successful_ && + (App.get_loop_component_start_time() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) { // successful boot, reset counter ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter"); this->mark_successful(); diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index cd1a084f16..a0dffe26bf 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -211,7 +211,7 @@ template class ScriptExecuteAction, T public: ScriptExecuteAction(Script *script) : script_(script) {} - using Args = std::tuple...>; + using Args = std::tuple...>; template void set_args(F... x) { args_ = Args{x...}; } diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index affbc0409e..8006d0b4ba 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -19,10 +19,13 @@ from esphome.const import ( CONF_REACTIVE_POWER, CONF_TOTAL_POWER, CONF_VOLTAGE, + DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, ICON_FLASH, @@ -67,11 +70,13 @@ PHASE_SENSORS = { CONF_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, accuracy_decimals=2, + device_class=DEVICE_CLASS_APPARENT_POWER, state_class=STATE_CLASS_MEASUREMENT, ), CONF_REACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, accuracy_decimals=2, + device_class=DEVICE_CLASS_REACTIVE_POWER, state_class=STATE_CLASS_MEASUREMENT, ), CONF_POWER_FACTOR: sensor.sensor_schema( @@ -80,7 +85,10 @@ PHASE_SENSORS = { state_class=STATE_CLASS_MEASUREMENT, ), CONF_PHASE_ANGLE: sensor.sensor_schema( - unit_of_measurement=UNIT_DEGREES, icon=ICON_FLASH, accuracy_decimals=3 + unit_of_measurement=UNIT_DEGREES, + icon=ICON_FLASH, + accuracy_decimals=3, + state_class=STATE_CLASS_MEASUREMENT, ), } @@ -99,6 +107,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=3, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_TOTAL_POWER): sensor.sensor_schema( 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/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index 7b20941aa4..ca15fd5be6 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -5,6 +5,7 @@ from esphome.const import ( DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ENERGY, DEVICE_CLASS_SPEED, + STATE_CLASS_MEASUREMENT, UNIT_METER, ) @@ -26,6 +27,7 @@ CONFIG_SCHEMA = cv.Schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, # Specify the number of decimal places icon="mdi:signal-distance-variant", + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_MOVEMENT_SIGNS): sensor.sensor_schema( icon="mdi:human-greeting-variant", @@ -34,6 +36,7 @@ CONFIG_SCHEMA = cv.Schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, icon="mdi:signal-distance-variant", + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CUSTOM_SPATIAL_STATIC_VALUE): sensor.sensor_schema( device_class=DEVICE_CLASS_ENERGY, @@ -48,6 +51,7 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_SPEED, accuracy_decimals=2, icon="mdi:run-fast", + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CUSTOM_MODE_NUM): sensor.sensor_schema( icon="mdi:counter", diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 069b61af5a..1a53eb5c37 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -14,10 +14,13 @@ from esphome.const import ( CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_VOLTAGE, + DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, @@ -97,11 +100,13 @@ SENSORS = { CONF_REACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, state_class=STATE_CLASS_MEASUREMENT, ), CONF_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, accuracy_decimals=3, + device_class=DEVICE_CLASS_APPARENT_POWER, state_class=STATE_CLASS_MEASUREMENT, ), CONF_VOLTAGE: sensor.sensor_schema( @@ -125,6 +130,7 @@ SENSORS = { unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, state_class=STATE_CLASS_MEASUREMENT, ), CONF_MAXIMUM_DEMAND_ACTIVE_POWER: sensor.sensor_schema( @@ -136,11 +142,13 @@ SENSORS = { CONF_MAXIMUM_DEMAND_REACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, state_class=STATE_CLASS_MEASUREMENT, ), CONF_MAXIMUM_DEMAND_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, accuracy_decimals=3, + device_class=DEVICE_CLASS_APPARENT_POWER, state_class=STATE_CLASS_MEASUREMENT, ), } diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index b2c17f59ac..ba5214e550 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -279,10 +279,14 @@ async def select_operation_to_code(config, action_id, template_arg, args): op_ = await cg.templatable(operation, args, SelectOperation) cg.add(var.set_operation(op_)) if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, bool) + template_ = await cg.templatable(cycle, args, cg.bool_) cg.add(var.set_cycle(template_)) if (mode := config.get(CONF_MODE)) is not None: - cg.add(var.set_operation(SELECT_OPERATION_OPTIONS[mode])) + template_ = await cg.templatable( + SELECT_OPERATION_OPTIONS[mode], args, SelectOperation + ) + cg.add(var.set_operation(template_)) if (cycle := config.get(CONF_CYCLE)) is not None: - cg.add(var.set_cycle(cycle)) + template_ = await cg.templatable(cycle, args, cg.bool_) + cg.add(var.set_cycle(template_)) return var diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 0e14371d00..9d2fb725f3 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -116,7 +116,7 @@ void SelectCall::perform() { auto idx = target_index.value(); // All operations use indices, call control() by index to avoid string conversion - ESP_LOGD(TAG, "'%s' - Set selected option to: %s", name, parent->option_at(idx)); + ESP_LOGV(TAG, "'%s' - Set selected option to: %s", name, parent->option_at(idx)); parent->control(idx); } 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/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 9fe51121f1..ce35cf5bf1 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - CONF_VOC_BASELINE, DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -165,7 +164,6 @@ CONFIG_SCHEMA = ( gain_factor=230, ), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, - cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 626466eefa..b658ff7056 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -106,6 +106,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, setup_device_class, @@ -290,7 +291,11 @@ ClampFilter = sensor_ns.class_("ClampFilter", Filter) RoundFilter = sensor_ns.class_("RoundFilter", Filter) RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter) -validate_unit_of_measurement = cv.string_strict +validate_unit_of_measurement = cv.All( + cv.string_strict, + # Keep in sync with max_data_length in api.proto + cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH), +) validate_accuracy_decimals = cv.int_ validate_icon = cv.icon validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -366,13 +371,13 @@ def sensor_schema( @FILTER_REGISTRY.register("offset", OffsetFilter, cv.templatable(cv.float_)) async def offset_filter_to_code(config, filter_id): - template_ = await cg.templatable(config, [], float) + template_ = await cg.templatable(config, [], cg.float_) return cg.new_Pvariable(filter_id, template_) @FILTER_REGISTRY.register("multiply", MultiplyFilter, cv.templatable(cv.float_)) async def multiply_filter_to_code(config, filter_id): - template_ = await cg.templatable(config, [], float) + template_ = await cg.templatable(config, [], cg.float_) return cg.new_Pvariable(filter_id, template_) @@ -384,7 +389,7 @@ async def multiply_filter_to_code(config, filter_id): async def filter_out_filter_to_code(config, filter_id): if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], float) for x in config] + template_ = [await cg.templatable(x, [], cg.float_) for x in config] return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) @@ -653,7 +658,7 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( async def throttle_with_priority_filter_to_code(config, filter_id): if not isinstance(config[CONF_VALUE], list): config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] + template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) @@ -708,7 +713,7 @@ async def timeout_filter_to_code(config, filter_id): else: # Use TimeoutFilterConfigured for configured value mode filter_id.type = TimeoutFilterConfigured - template_ = await cg.templatable(config[CONF_VALUE], [], float) + template_ = await cg.templatable(config[CONF_VALUE], [], cg.float_) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) await cg.register_component(var, {}) return var @@ -887,24 +892,27 @@ 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) if (above := conf.get(CONF_ABOVE)) is not None: - template_ = await cg.templatable(above, [(float, "x")], float) + template_ = await cg.templatable(above, [(float, "x")], cg.float_) cg.add(trigger.set_min(template_)) if (below := conf.get(CONF_BELOW)) is not None: - template_ = await cg.templatable(below, [(float, "x")], float) + template_ = await cg.templatable(below, [(float, "x")], cg.float_) cg.add(trigger.set_max(template_)) await automation.build_automation(trigger, [(float, "x")], conf) diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index b4de712727..37578f5320 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -79,8 +79,8 @@ class ValueRangeTrigger : public Trigger, public Component { Sensor *parent_; ESPPreferenceObject rtc_; bool previous_in_range_{false}; - TemplatableValue min_{NAN}; - TemplatableValue max_{NAN}; + TemplatableFn min_{[](float) -> float { return NAN; }}; + TemplatableFn max_{[](float) -> float { return NAN; }}; }; template class SensorInRangeCondition : public Condition { diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 6a90a5af66..fbac7d3535 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -213,17 +213,17 @@ optional LambdaFilter::new_value(float value) { } // OffsetFilter -OffsetFilter::OffsetFilter(TemplatableValue offset) : offset_(std::move(offset)) {} +OffsetFilter::OffsetFilter(TemplatableFn offset) : offset_(offset) {} optional OffsetFilter::new_value(float value) { return value + this->offset_.value(); } // MultiplyFilter -MultiplyFilter::MultiplyFilter(TemplatableValue multiplier) : multiplier_(std::move(multiplier)) {} +MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(multiplier) {} optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } // ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count) { +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { int8_t accuracy = parent->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); @@ -258,7 +258,7 @@ optional ThrottleFilter::new_value(float value) { } // ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { const uint32_t now = App.get_loop_component_start_time(); if (last_input == 0 || now - last_input >= min_time_between_inputs || diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index cb4abd154a..0dbbc33ab3 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -311,26 +311,26 @@ class StatelessLambdaFilter : public Filter { /// A simple filter that adds `offset` to each value it receives. class OffsetFilter : public Filter { public: - explicit OffsetFilter(TemplatableValue offset); + explicit OffsetFilter(TemplatableFn offset); optional new_value(float value) override; protected: - TemplatableValue offset_; + TemplatableFn offset_; }; /// A simple filter that multiplies to each value it receives by `multiplier`. class MultiplyFilter : public Filter { public: - explicit MultiplyFilter(TemplatableValue multiplier); + explicit MultiplyFilter(TemplatableFn multiplier); optional new_value(float value) override; protected: - TemplatableValue multiplier_; + TemplatableFn multiplier_; }; /// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count); +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); /** Base class for filters that compare sensor values against a fixed list of configured values. * @@ -342,7 +342,7 @@ bool value_list_matches_any(Sensor *parent, float sensor_value, const Templatabl */ template class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { + explicit ValueListFilter(std::initializer_list> values) { init_array_from(this->values_, values); } @@ -351,13 +351,13 @@ template class ValueListFilter : public Filter { return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); } - std::array, N> values_{}; + std::array, N> values_{}; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. template class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) + explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { @@ -379,14 +379,14 @@ class ThrottleFilter : public Filter { }; /// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. template class ThrottleWithPriorityFilter : public ValueListFilter { public: explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) + std::initializer_list> prioritized_values) : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} optional new_value(float value) override { @@ -430,15 +430,15 @@ class TimeoutFilterLast : public TimeoutFilterBase { // Timeout filter with configured value - evaluates TemplatableValue after timeout class TimeoutFilterConfigured : public TimeoutFilterBase { public: - explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableValue &new_value) + explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableFn &new_value) : TimeoutFilterBase(time_period), value_(new_value) {} optional new_value(float value) override; protected: float get_output_value() override { return this->value_.value(); } - TemplatableValue value_; // 16 bytes (configured output value, can be lambda) - // Total: 8 (base) + 16 = 24 bytes + vtable ptr + Component overhead + TemplatableFn value_; // 4 bytes (configured output value, can be lambda) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; class DebounceFilter : public Filter, public Component { diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index a23bb53536..c2eaefe455 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -67,7 +67,7 @@ async def to_code(config): async def servo_write_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, float) + template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index c96bc380d7..1688f9d6a6 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -22,6 +22,7 @@ from esphome.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, UNIT_AMPERE, UNIT_VOLT, UNIT_WATT, @@ -66,7 +67,7 @@ KNOWN_FIRMWARE = { def parse_firmware_version(value): - match = re.match(r"(\d+).(\d+)", value) + match = re.fullmatch(r"(\d+)\.(\d+)", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) @@ -128,7 +129,7 @@ def validate_firmware(value): def validate_sha256(value): value = cv.string(value) - if not value.isalnum() or not len(value) == 64: + if not re.fullmatch(r"[0-9a-fA-F]{64}", value): raise ValueError(f"Not a valid SHA256 hex string: {value}") return value @@ -163,16 +164,19 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_WATT, accuracy_decimals=1, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CURRENT): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, device_class=DEVICE_CLASS_CURRENT, accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, ), # Change the default gamma_correct setting. cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, 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 1bf0d97d65..d25e883fa1 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -19,31 +19,38 @@ CONF_OBIS_CODE = "obis_code" CONF_SERVER_ID = "server_id" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(Sml), - cv.Optional(CONF_ON_DATA): automation.validate_automation({}), - } -).extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Sml), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_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/sml/sensor/__init__.py b/esphome/components/sml/sensor/__init__.py index 164a31f8a7..e6d7180f17 100644 --- a/esphome/components/sml/sensor/__init__.py +++ b/esphome/components/sml/sensor/__init__.py @@ -10,13 +10,17 @@ AUTO_LOAD = ["sml"] SmlSensor = sml_ns.class_("SmlSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema().extend( - { - cv.GenerateID(): cv.declare_id(SmlSensor), - cv.GenerateID(CONF_SML_ID): cv.use_id(Sml), - cv.Required(CONF_OBIS_CODE): obis_code, - cv.Optional(CONF_SERVER_ID, default=""): cv.string, - } +CONFIG_SCHEMA = ( + sensor.sensor_schema() + .extend( + { + cv.GenerateID(): cv.declare_id(SmlSensor), + cv.GenerateID(CONF_SML_ID): cv.use_id(Sml), + cv.Required(CONF_OBIS_CODE): obis_code, + cv.Optional(CONF_SERVER_ID, default=""): cv.string, + } + ) + .extend(cv.COMPONENT_SCHEMA) ) diff --git a/esphome/components/sml/text_sensor/__init__.py b/esphome/components/sml/text_sensor/__init__.py index 9c9da26c3a..5a5ab658c4 100644 --- a/esphome/components/sml/text_sensor/__init__.py +++ b/esphome/components/sml/text_sensor/__init__.py @@ -19,13 +19,17 @@ SML_TYPES = { SmlTextSensor = sml_ns.class_("SmlTextSensor", text_sensor.TextSensor, cg.Component) -CONFIG_SCHEMA = text_sensor.text_sensor_schema(SmlTextSensor).extend( - { - cv.GenerateID(CONF_SML_ID): cv.use_id(Sml), - cv.Required(CONF_OBIS_CODE): obis_code, - cv.Optional(CONF_SERVER_ID, default=""): cv.string, - cv.Optional(CONF_FORMAT, default=""): cv.enum(SML_TYPES, lower=True), - } +CONFIG_SCHEMA = ( + text_sensor.text_sensor_schema(SmlTextSensor) + .extend( + { + cv.GenerateID(CONF_SML_ID): cv.use_id(Sml), + cv.Required(CONF_OBIS_CODE): obis_code, + cv.Optional(CONF_SERVER_ID, default=""): cv.string, + cv.Optional(CONF_FORMAT, default=""): cv.enum(SML_TYPES, lower=True), + } + ) + .extend(cv.COMPONENT_SCHEMA) ) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 08cf3ea33c..abbbb0f056 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -31,9 +31,6 @@ MIN_UDP_SOCKETS = 6 # Minimum listening sockets — at least api + ota baseline. MIN_TCP_LISTEN_SOCKETS = 2 -# Wake loop threadsafe support tracking -KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required" - class SocketType(StrEnum): TCP = "tcp" @@ -123,37 +120,22 @@ def get_socket_counts() -> SocketCounts: def require_wake_loop_threadsafe() -> None: - """Mark that wake_loop_threadsafe support is required by a component. + """Deprecated: wake loop support is now always available on all platforms. - Call this from components that need to wake the main event loop from background threads. - This enables the shared UDP loopback socket mechanism (~208 bytes RAM). - The socket is shared across all components that use this feature. - - This call is a no-op if networking is not enabled in the configuration. - - IMPORTANT: This is for background thread context only, NOT ISR context. - Socket operations are not safe to call from ISR handlers. - - On ESP32, FreeRTOS task notifications are used instead (no socket needed). - - Example: - from esphome.components import socket - - async def to_code(config): - socket.require_wake_loop_threadsafe() + This function adds backward-compatible defines so external components + that check #ifdef USE_WAKE_LOOP_THREADSAFE / USE_SOCKET_SELECT_SUPPORT + continue to compile. Remove before 2026.12.0. """ - - # Only set up once (idempotent - multiple components can call this) - if CORE.has_networking and not CORE.data.get( - KEY_WAKE_LOOP_THREADSAFE_REQUIRED, False - ): - CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True - cg.add_define("USE_WAKE_LOOP_THREADSAFE") - if not CORE.is_esp32 and not CORE.is_libretiny: - # Only platforms without fast select need a UDP socket for wake - # notifications. ESP32 and LibreTiny use FreeRTOS task notifications - # instead (no socket needed). - consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) + # Remove before 2026.12.0 + _LOGGER.warning( + "require_wake_loop_threadsafe() is deprecated and no longer needed. " + "Wake loop support is now always available. Remove this call and any " + "#ifdef USE_SOCKET_SELECT_SUPPORT / USE_WAKE_LOOP_THREADSAFE guards. " + "This will be removed in 2026.12.0." + ) + # Add deprecated defines for backward compat with external component C++ code + cg.add_define("USE_WAKE_LOOP_THREADSAFE") + cg.add_define("USE_SOCKET_SELECT_SUPPORT") CONFIG_SCHEMA = cv.Schema( @@ -184,10 +166,8 @@ async def to_code(config): cg.add_define("USE_SOCKET_IMPL_LWIP_TCP") elif impl == IMPLEMENTATION_LWIP_SOCKETS: cg.add_define("USE_SOCKET_IMPL_LWIP_SOCKETS") - cg.add_define("USE_SOCKET_SELECT_SUPPORT") elif impl == IMPLEMENTATION_BSD_SOCKETS: cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") - cg.add_define("USE_SOCKET_SELECT_SUPPORT") # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() # and FreeRTOS task notifications — enable fast select to bypass lwip_select(). # Only when not using lwip_tcp, which does not provide select() support. diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 339a699bc9..e520784702 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -112,6 +112,8 @@ class BSDSocketImpl { int setblocking(bool blocking); int loop() { return 0; } + /// Check if the socket has buffered data ready to read. + /// See the ready() contract in socket.h — callers must drain or track remaining data. bool ready() const; int get_fd() const { return this->fd_; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 69a8e0e9c8..abb6c2537d 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -8,6 +8,7 @@ #include #include "esphome/core/helpers.h" +#include "esphome/core/wake.h" #include "esphome/core/log.h" #include "lwip/igmp.h" @@ -23,102 +24,6 @@ namespace esphome::socket { -#ifdef USE_ESP8266 -// Flag to signal socket activity - checked by socket_delay() to exit early -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_socket_woke = false; - -void socket_delay(uint32_t ms) { - // Use esp_delay with a callback that checks if socket data arrived. - // This allows the delay to exit early when socket_wake() is called by - // lwip recv_fn/accept_fn callbacks, reducing socket latency. - // - // When ms is 0, we must use delay(0) because esp_delay(0, callback) - // exits immediately without yielding, which can cause watchdog timeouts - // when the main loop runs in high-frequency mode (e.g., during light effects). - if (ms == 0) { - delay(0); - return; - } - s_socket_woke = false; - esp_delay(ms, []() { return !s_socket_woke; }); -} - -void IRAM_ATTR socket_wake() { - s_socket_woke = true; - esp_schedule(); -} -#elif defined(USE_RP2040) -// RP2040 (non-FreeRTOS) socket wake using hardware WFE/SEV instructions. -// -// Same pattern as ESP8266's esp_delay()/esp_schedule(): set a one-shot timer, -// then sleep with __wfe(). Wake on either: -// - Timer alarm fires → callback calls __sev() → __wfe() returns → timeout -// - Socket data arrives → LWIP callback calls socket_wake() → __sev() → __wfe() returns → early wake -// -// CYW43 WiFi chip communicates via SPI interrupts on core 0. When data arrives, -// the GPIO interrupt fires → async_context pendsv processes CYW43/LWIP → recv/accept -// callbacks call socket_wake() → __sev() wakes the main loop from __wfe() sleep. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_socket_woke = false; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - // Wake the main loop from __wfe() sleep — timeout expired. - __sev(); - // Return 0 = don't reschedule (one-shot) - return 0; -} - -void socket_delay(uint32_t ms) { - if (ms == 0) { - yield(); - return; - } - // If a wake was already signalled, consume it and return immediately - // instead of going to sleep. This avoids losing a wake that arrived - // between loop iterations. - if (s_socket_woke) { - s_socket_woke = false; - return; - } - // Don't clear s_socket_woke here — if an IRQ fires between the check above - // and the while loop below, the while condition sees it immediately. Clearing - // here would lose that wake and sleep until the timer fires. - s_delay_expired = false; - // Set a one-shot timer to wake us after the timeout. - // add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error. - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - // Sleep until woken by either the timer alarm or socket_wake(). - // __wfe() may return spuriously (stale event register, other interrupts), - // so we loop checking both flags. - while (!s_socket_woke && !s_delay_expired) { - __wfe(); - } - // Cancel timer if we woke early (socket data arrived before timeout) - if (!s_delay_expired) - cancel_alarm(alarm); - s_socket_woke = false; // consume the wake for next call -} - -// No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP -// callbacks via pendsv (not hard IRQ), so they execute from flash safely. -void socket_wake() { - s_socket_woke = true; - // Wake the main loop from __wfe() sleep. __sev() is a global event that - // wakes any core sleeping in __wfe(). This is ISR-safe. - __sev(); -} -#endif - // ---- LWIP thread safety ---- // // On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. @@ -571,10 +476,8 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { } else { pbuf_cat(this->rx_buf_, pb); } -#if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can process the received data. - socket_wake(); -#endif + esphome::wake_loop_any_context(); return ERR_OK; } @@ -583,15 +486,15 @@ void LWIPRawImpl::wait_for_data_() { // (needs async_context lock). // // Loop until data arrives, connection closes, or the full timeout elapses. - // socket_delay() may return early due to other sockets waking the global - // socket_wake() flag, so we re-enter for the remaining time. + // wakeable_delay() may return early due to any wake source, + // so we re-enter for the remaining time. uint32_t timeout_ms = this->recv_timeout_cs_ * 10; uint32_t start = millis(); while (this->waiting_for_data_()) { uint32_t elapsed = millis() - start; if (elapsed >= timeout_ms) break; - socket_delay(timeout_ms - elapsed); + esphome::internal::wakeable_delay(timeout_ms - elapsed); } } @@ -979,10 +882,8 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); -#if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can accept the new connection. - socket_wake(); -#endif + esphome::wake_loop_any_context(); return ERR_OK; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 5900950707..72ea7d547e 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -97,6 +97,8 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ENOSYS; return -1; } + // Check if the socket has buffered data ready to read. + // See the ready() contract in socket.h — callers must drain or track remaining data. // Intentionally unlocked — this is a polling check called every loop iteration. // A stale read at worst delays processing by one loop tick; the actual I/O in // read() holds the lwip lock and re-checks properly. See esphome#10681. @@ -110,7 +112,7 @@ class LWIPRawImpl : public LWIPRawCommon { return -1; } // Raw TCP doesn't use a blocking flag directly. Blocking behavior - // is provided by SO_RCVTIMEO which makes read() wait via socket_delay(). + // is provided by SO_RCVTIMEO which makes read() wait via wakeable_delay(). return 0; } int loop() { return 0; } diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index bfc4da9926..942d0ccf85 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -78,6 +78,8 @@ class LwIPSocketImpl { int setblocking(bool blocking); int loop() { return 0; } + /// Check if the socket has buffered data ready to read. + /// See the ready() contract in socket.h — callers must drain or track remaining data. bool ready() const; int get_fd() const { return this->fd_; } diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index d039c5436e..abce03ae23 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,7 +8,7 @@ namespace esphome::socket { -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST // Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). // Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 7303cea96b..8f84df9c30 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -52,7 +52,7 @@ using UDPRecvSocket = LWIPRawUDPRecvImpl; inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); } -#elif defined(USE_SOCKET_SELECT_SUPPORT) +#elif defined(USE_HOST) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); @@ -60,6 +60,19 @@ bool socket_ready_fd(int fd, bool loop_monitored); // Inline ready() — defined here because it depends on socket_ready/socket_ready_fd // declared above, while the impl headers are included before those declarations. +// +// Contract (applies to ALL socket implementations — each platform implements +// ready() differently, but this contract holds regardless of the mechanism): +// ready() checks if the socket has buffered data ready to read. When it returns +// true, the caller MUST read until it would block (EAGAIN/EWOULDBLOCK), or until +// read() returns 0 to indicate EOF / connection closed, or track that it stopped +// early and retry without calling ready(). The next call to ready() will only +// report new data correctly if all callers fulfill this contract. Failing to +// drain the socket may cause ready() to return false while data remains readable. +// +// In practice each socket is owned by a single component, so this contract is +// straightforward to fulfill — but the owning component must be aware of it, +// especially if it limits how many messages it processes per loop iteration. #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) inline bool Socket::ready() const { #ifdef USE_LWIP_FAST_SELECT @@ -137,19 +150,5 @@ socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t po /// Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf); -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) -/// Delay that can be woken early by socket activity. -/// On ESP8266, uses esp_delay() with a callback that checks socket activity. -/// On RP2040, uses __wfe() (Wait For Event) to truly sleep until an interrupt -/// (for example, CYW43 GPIO or a timer alarm) fires and wakes the CPU. -void socket_delay(uint32_t ms); // NOLINT(readability-redundant-declaration) - -/// Signal socket/IO activity and wake the main loop early. -/// On ESP8266: sets flag + esp_schedule(). -/// On RP2040: sets flag + __sev() (Send Event) to wake from __wfe(). -/// ISR-safe on both platforms. -void socket_wake(); // NOLINT(readability-redundant-declaration) -#endif - } // namespace esphome::socket #endif diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 8480eebcdb..98b5abe58c 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -127,7 +127,7 @@ automation.register_condition( async def speaker_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, float) + volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) cg.add(var.set_volume(volume)) return var diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b16f882cba..320e96c897 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -516,7 +516,8 @@ async def play_on_device_media_media_action(config, action_id, template_arg, arg announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) enqueue = await cg.templatable(config[CONF_ENQUEUE], args, cg.bool_) - cg.add(var.set_audio_file(media_file)) + template_ = await cg.templatable(media_file, args, audio.AudioFile.operator("ptr")) + cg.add(var.set_audio_file(template_)) cg.add(var.set_announcement(announcement)) cg.add(var.set_enqueue(enqueue)) return var diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 7f0f776ee5..70feeac318 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -312,7 +312,8 @@ async def set_playlist_delay_action_to_code( parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) - cg.add(var.set_pipeline(config[CONF_PIPELINE])) + template_ = await cg.templatable(config[CONF_PIPELINE], args, cg.uint8) + cg.add(var.set_pipeline(template_)) template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) cg.add(var.set_delay(template_)) diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index d45237c467..eaa8a55858 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -9,7 +9,6 @@ static const char *const TAG = "speed.fan"; void SpeedFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, this->direction_ != nullptr, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index e9a389e0f3..db96039a13 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -16,8 +16,11 @@ class SpeedFan : public Component, public fan::Fan { void set_output(output::FloatOutput *output) { this->output_ = output; } void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } - fan::FanTraits get_traits() override { return this->traits_; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; @@ -28,7 +31,6 @@ class SpeedFan : public Component, public fan::Fan { output::BinaryOutput *direction_{nullptr}; int speed_count_{}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace speed diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 931882be8d..33ccfbb5ee 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -246,11 +246,15 @@ def validate_hw_pins(spi, index=-1): return clk_pin_no >= 0 if target_platform == PLATFORM_RP2040: - pin_set = ( - list(filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS))[0] - if index == -1 - else RP_SPI_PINSETS[index] - ) + if index == -1: + matches = list( + filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS) + ) + if not matches: + return False + pin_set = matches[0] + else: + pin_set = RP_SPI_PINSETS[index] if pin_set is None: return False if sdo_pin_no not in pin_set[CONF_MOSI_PIN]: diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 36344a6d38..20359135ba 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -68,7 +68,7 @@ void SPIComponent::dump_config() { LOG_PIN(" SDI Pin: ", this->sdi_pin_); LOG_PIN(" SDO Pin: ", this->sdo_pin_); for (size_t i = 0; i != this->data_pins_.size(); i++) { - ESP_LOGCONFIG(TAG, " Data pin %u: GPIO%d", i, this->data_pins_[i]); + ESP_LOGCONFIG(TAG, " Data pin %zu: GPIO%d", i, this->data_pins_[i]); } if (this->spi_bus_->is_hw()) { ESP_LOGCONFIG(TAG, " Using HW SPI: %s", this->interface_name_); @@ -118,4 +118,12 @@ uint16_t SPIDelegateBitBash::transfer_(uint16_t data, size_t num_bits) { return out_data; } +#if !defined(USE_ESP32) && !defined(USE_ARDUINO) +// Stub for unsupported platforms (host, Zephyr, etc.) - hardware SPI is unavailable +SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo, GPIOPin *sdi, + const std::vector &data_pins) { + return nullptr; +} +#endif + } // namespace esphome::spi diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 84c8bca267..dc538f4c41 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -23,9 +23,9 @@ using SPIInterface = SPIClassRP2040 *; using SPIInterface = SPIClass *; #endif -#elif defined(CLANG_TIDY) +#elif defined(USE_HOST) || defined(CLANG_TIDY) -using SPIInterface = void *; // Stub for platforms without SPI (e.g., Zephyr) +using SPIInterface = void *; // Stub for platforms without SPI (e.g., host, Zephyr) #endif // USE_ESP32 / USE_ARDUINO diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 6e2ff4ee2e..efa5b0bf15 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -272,7 +272,7 @@ SPRINKLER_VALVE_SCHEMA = cv.Schema( ), cv.Optional( CONF_UNIT_OF_MEASUREMENT, default=UNIT_SECOND - ): cv.one_of(UNIT_MINUTE, UNIT_SECOND, lower="True"), + ): cv.one_of(UNIT_MINUTE, UNIT_SECOND, lower=True), } ) .extend(cv.COMPONENT_SCHEMA), @@ -427,7 +427,7 @@ CONFIG_SCHEMA = cv.All( async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DIVIDER], args, cg.float_) + template_ = await cg.templatable(config[CONF_DIVIDER], args, cg.uint32) cg.add(var.set_divider(template_)) return var @@ -455,7 +455,7 @@ async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8) + template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) cg.add(var.set_valve_number(template_)) template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) cg.add(var.set_valve_run_duration(template_)) @@ -471,7 +471,7 @@ async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, ar async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.float_) + template_ = await cg.templatable(config[CONF_REPEAT], args, cg.uint32) cg.add(var.set_repeat(template_)) return var @@ -487,7 +487,7 @@ async def sprinkler_set_valve_run_duration_to_code( ): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8) + template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) cg.add(var.set_valve_number(template_)) template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) cg.add(var.set_valve_run_duration(template_)) @@ -525,7 +525,7 @@ async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, ar async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8) + template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) cg.add(var.set_valve_to_start(template_)) if CONF_RUN_DURATION in config: template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index b3f030805d..c6fe2e4e02 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -108,7 +108,8 @@ template class StartSingleValveAction : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - TEMPLATABLE_VALUE(size_t, valve_to_start) + // TemplatableValue (not TemplatableFn) — also set from C++ with raw values in sprinkler.cpp + template void set_valve_to_start(V valve_to_start) { this->valve_to_start_ = valve_to_start; } TEMPLATABLE_VALUE(uint32_t, valve_run_duration) void play(const Ts &...x) override { @@ -118,6 +119,7 @@ template class StartSingleValveAction : public Action { protected: Sprinkler *sprinkler_; + TemplatableValue valve_to_start_{}; }; template class ShutdownAction : public Action { diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index a8b12dfa28..7f6492812f 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -138,7 +138,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_INIT_SEQUENCE, default=1): cv.ensure_list( map_sequence ), - cv.Optional(CONF_COLOR_ORDER): cv.one_of( + cv.Optional(CONF_COLOR_ORDER, default="BGR"): cv.one_of( *COLOR_ORDERS.keys(), upper=True ), cv.Optional(CONF_PCLK_FREQUENCY, default="16MHz"): cv.All( diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index ba854bb0ae..62dfa6f413 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -1,3 +1,8 @@ import esphome.codegen as cg st7735_ns = cg.esphome_ns.namespace("st7735") + +DEPRECATED_COMPONENT = """ +The 'st7735' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 9dc69f27ff..766370c21f 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -15,6 +17,7 @@ from esphome.const import ( from . import st7735_ns CODEOWNERS = ["@SenexCrenshaw"] +LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["spi"] @@ -87,6 +90,9 @@ async def setup_st7735(var, config): async def to_code(config): + LOGGER.warning( + "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable( config[CONF_ID], config[CONF_MODEL], diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index c9f4199616..85414237cf 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -127,7 +127,7 @@ def validate_st7789v(config): if model_data[REQUIRE_PS] and CONF_POWER_SUPPLY not in config: raise cv.Invalid( - f'{CONF_POWER_SUPPLY} must be specified when {CONF_MODEL} is {config[CONF_MODEL]}"' + f"{CONF_POWER_SUPPLY} must be specified when {CONF_MODEL} is {config[CONF_MODEL]}" ) if ( diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 27d4fc276d..8acacc3b49 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -46,7 +46,7 @@ def validate_acceleration(value): def validate_speed(value): value = cv.string(value) - for suffix in ("steps/s", "steps/s"): + for suffix in ("steps/s",): value = value.removesuffix(suffix) if value == "inf": diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index aab1712b65..c0bd9d7be9 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -2,6 +2,7 @@ from collections import ChainMap import logging from typing import Any +import esphome from esphome import core from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered import esphome.config_validation as cv @@ -12,6 +13,7 @@ from esphome.yaml_util import ( ConfigContext, ESPHomeDataBase, ESPLiteralValue, + IncludeFile, make_data_base, ) @@ -291,6 +293,59 @@ def push_context( return parent_context +def resolve_include( + include: IncludeFile, + path: list[int | str], + context_vars: ContextVars, + strict_undefined: bool = True, + errors: ErrList | None = None, +) -> tuple[Any, str]: + """Resolve an include, substituting the filename if needed. + + Returns the loaded content and the resolved filename. + + Note: no path-traversal validation is performed on the resolved filename. + A substitution that resolves to an absolute path will bypass the parent + directory (Path.__truediv__ ignores the left operand for absolute paths). + ESPHome's trust model assumes the config author controls all substitution + values (including command-line substitutions), so path restrictions are + an explicit non-goal here. + """ + original = str(include.file) + filename = str( + _expand_substitutions( + original, path + ["file"], context_vars, strict_undefined, errors + ) + ) + if filename != original: + include = IncludeFile( + include.parent_file, filename, include.vars, include.yaml_loader + ) + try: + return include.load(), filename + except esphome.core.EsphomeError as err: + raise cv.Invalid( + f"Error including file '{filename}': {err}", + path + [f"<{filename}>"], + ) from err + + +def _substitute_include( + include: IncludeFile, + path: list[int | str], + context_vars: ContextVars, + strict_undefined: bool, + errors: ErrList | None, +) -> Any: + """Resolve an include and substitute its content.""" + content, filename = resolve_include( + include, path, context_vars, strict_undefined, errors + ) + return substitute( + content, path + [f"<{filename}>"], context_vars, strict_undefined, errors + ) + + def substitute( item: Any, path: SubstitutionPath, @@ -333,6 +388,9 @@ def substitute( if item.value != value: result = type(item)(value) + elif isinstance(item, IncludeFile): + result = _substitute_include(item, path, context_vars, strict_undefined, errors) + if isinstance(item, ESPHomeDataBase): result = make_data_base(result, item) return result diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index 37e9fa4d2d..36a7425a69 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -2,7 +2,6 @@ from ast import literal_eval from collections.abc import Iterator, Mapping from itertools import chain, islice import math -import re from types import GeneratorType from typing import Any @@ -10,6 +9,9 @@ import jinja2 as jinja from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate from jinja2.runtime import missing as Missing +# Re-exported for backward compatibility — consumers import has_jinja from here +from esphome.expression import has_jinja # noqa: F401 # pylint: disable=unused-import + TemplateError = jinja.TemplateError TemplateSyntaxError = jinja.TemplateSyntaxError TemplateRuntimeError = jinja.TemplateRuntimeError @@ -20,18 +22,6 @@ Undefined = jinja.Undefined Resolver = ".resolver" -DETECT_JINJA = r"(\$\{)" -detect_jinja_re = re.compile( - r"<%.+?%>" # Block form expression: <% ... %> - r"|\$\{[^}]+\}", # Braced form expression: ${ ... } - flags=re.MULTILINE, -) - - -def has_jinja(st: str) -> bool: - return detect_jinja_re.search(st) is not None - - # SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose # in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow # arbitrary code execution, file access, or other security risks are included. diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index a356d9cca8..a1ced8ff5b 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ICON_WEATHER_SUNSET, UNIT_DEGREES +from esphome.const import ( + CONF_TYPE, + ICON_WEATHER_SUNSET, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, +) from .. import CONF_SUN_ID, Sun, sun_ns @@ -20,6 +25,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_DEGREES, icon=ICON_WEATHER_SUNSET, accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, ) .extend( { diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index d073c16e4e..c7082794db 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -13,11 +13,15 @@ sun_gtil2_ns = cg.esphome_ns.namespace("sun_gtil2") SunGTIL2Component = sun_gtil2_ns.class_("SunGTIL2", cg.Component, uart.UARTDevice) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SunGTIL2Component), - } -).extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SunGTIL2Component), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) async def to_code(config): diff --git a/esphome/components/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index d8c59bf1de..55c8195391 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_VOLTAGE, ICON_FLASH, ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_VOLT, UNIT_WATT, @@ -30,36 +31,42 @@ CONFIG_SCHEMA = cv.All( icon=ICON_FLASH, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DC_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, icon=ICON_FLASH, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AC_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_WATT, icon=ICON_FLASH, accuracy_decimals=1, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DC_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_WATT, icon=ICON_FLASH, accuracy_decimals=1, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_LIMITER_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_WATT, icon=ICON_FLASH, accuracy_decimals=1, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, accuracy_decimals=1, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c4dd4856e3..9fa4a013ff 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") @@ -187,7 +196,7 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id( async def switch_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 11840db3a3..abc7338a62 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -18,15 +18,15 @@ void Switch::control(bool target_state) { } } void Switch::turn_on() { - ESP_LOGD(TAG, "'%s' Turning ON.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Turning ON.", this->get_name().c_str()); this->write_state(!this->inverted_); } void Switch::turn_off() { - ESP_LOGD(TAG, "'%s' Turning OFF.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Turning OFF.", this->get_name().c_str()); this->write_state(this->inverted_); } void Switch::toggle() { - ESP_LOGD(TAG, "'%s' Toggling %s.", this->get_name().c_str(), this->state ? "OFF" : "ON"); + ESP_LOGV(TAG, "'%s' Toggling %s.", this->get_name().c_str(), this->state ? "OFF" : "ON"); this->write_state(this->inverted_ == this->state); } optional Switch::get_initial_state() { 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/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b436c70120..150484d97a 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,6 +1,12 @@ import esphome.codegen as cg from esphome.components import sensor -from esphome.const import CONF_ID, ICON_FLASH, UNIT_WATT_HOURS +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_ENERGY, + ICON_FLASH, + STATE_CLASS_TOTAL_INCREASING, + UNIT_WATT_HOURS, +) from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -11,6 +17,8 @@ CONFIG_SCHEMA = sensor.sensor_schema( unit_of_measurement=UNIT_WATT_HOURS, icon=ICON_FLASH, accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ).extend(TELEINFO_LISTENER_SCHEMA) diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index e537e1f97c..8f57df91c5 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -64,6 +64,6 @@ async def to_code(config): async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index ea4da4e73c..a30c0af313 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -130,13 +130,13 @@ async def cover_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_STATE in config: - template_ = await cg.templatable(config[CONF_STATE], args, float) + template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_position(template_)) if CONF_POSITION in config: - template_ = await cg.templatable(config[CONF_POSITION], args, float) + template_ = await cg.templatable(config[CONF_POSITION], args, cg.float_) cg.add(var.set_position(template_)) if CONF_TILT in config: - template_ = await cg.templatable(config[CONF_TILT], args, float) + template_ = await cg.templatable(config[CONF_TILT], args, cg.float_) cg.add(var.set_tilt(template_)) if CONF_CURRENT_OPERATION in config: template_ = await cg.templatable( diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 46a5cba9bb..431be84654 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -9,7 +9,6 @@ void TemplateFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->has_oscillating_, this->speed_count_ > 0, this->has_direction_, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index b7e1d4ab5a..5ab6ae8c65 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -13,8 +13,11 @@ class TemplateFan final : public Component, public fan::Fan { void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; } void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_speed_count(int count) { this->speed_count_ = count; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } - fan::FanTraits get_traits() override { return this->traits_; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; @@ -23,7 +26,6 @@ class TemplateFan final : public Component, public fan::Fan { bool has_direction_{false}; int speed_count_{0}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace esphome::template_ diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index b0f48ade46..0c875bba0f 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -49,6 +49,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, float) + template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index eb6f0f46de..ca986365ed 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -85,6 +85,6 @@ async def to_code(config): async def switch_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, bool) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) cg.add(var.set_state(template_)) return var diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index 3e8fd81603..a2d0c19880 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -118,10 +118,10 @@ async def valve_template_publish_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, float) + template_ = await cg.templatable(state_config, args, cg.float_) cg.add(var.set_position(template_)) if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, float) + template_ = await cg.templatable(position_config, args, cg.float_) cg.add(var.set_position(template_)) if current_operation_config := config.get(CONF_CURRENT_OPERATION): template_ = await cg.templatable( diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 814aa40193..7f8a82c916 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -146,11 +146,11 @@ async def water_heater_template_publish_to_code( await cg.register_parented(var, config[CONF_ID]) if current_temp := config.get(CONF_CURRENT_TEMPERATURE): - template_ = await cg.templatable(current_temp, args, float) + template_ = await cg.templatable(current_temp, args, cg.float_) cg.add(var.set_current_temperature(template_)) if target_temp := config.get(CONF_TARGET_TEMPERATURE): - template_ = await cg.templatable(target_temp, args, float) + template_ = await cg.templatable(target_temp, args, cg.float_) cg.add(var.set_target_temperature(template_)) if mode := config.get(CONF_MODE): @@ -158,11 +158,11 @@ async def water_heater_template_publish_to_code( cg.add(var.set_mode(template_)) if CONF_AWAY in config: - template_ = await cg.templatable(config[CONF_AWAY], args, bool) + template_ = await cg.templatable(config[CONF_AWAY], args, cg.bool_) cg.add(var.set_away(template_)) if CONF_IS_ON in config: - template_ = await cg.templatable(config[CONF_IS_ON], args, bool) + template_ = await cg.templatable(config[CONF_IS_ON], args, cg.bool_) cg.add(var.set_is_on(template_)) return var 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/thermostat/climate.py b/esphome/components/thermostat/climate.py index ec115296d7..d609e22ac2 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -118,10 +118,8 @@ PRESET_CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_MODE): validate_climate_mode, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, - cv.Optional(CONF_FAN_MODE): cv.templatable(climate.validate_climate_fan_mode), - cv.Optional(CONF_SWING_MODE): cv.templatable( - climate.validate_climate_swing_mode - ), + cv.Optional(CONF_FAN_MODE): climate.validate_climate_fan_mode, + cv.Optional(CONF_SWING_MODE): climate.validate_climate_swing_mode, } ) @@ -631,7 +629,7 @@ CONFIG_SCHEMA = cv.All( ): automation.validate_automation(single=True), cv.Optional(CONF_HUMIDITY_HYSTERESIS, default=1.0): cv.percentage, cv.Optional(CONF_DEFAULT_MODE, default=None): cv.valid, - cv.Optional(CONF_DEFAULT_PRESET): cv.templatable(cv.string), + cv.Optional(CONF_DEFAULT_PRESET): cv.string, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, cv.Optional( diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d979359c1f..d8478d2648 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -333,15 +333,7 @@ climate::ClimateTraits ThermostatClimate::traits() { traits.add_supported_preset(entry.preset); } - // Extract custom preset names from the custom_preset_config_ vector - if (!this->custom_preset_config_.empty()) { - std::vector custom_preset_names; - custom_preset_names.reserve(this->custom_preset_config_.size()); - for (const auto &entry : this->custom_preset_config_) { - custom_preset_names.push_back(entry.name); - } - traits.set_supported_custom_presets(custom_preset_names); - } + // Custom presets are stored on Climate base class and wired via get_traits() return traits; } @@ -1306,6 +1298,13 @@ void ThermostatClimate::set_preset_config(std::initializer_list pre void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { this->custom_preset_config_ = presets; + // Populate Climate base class custom presets vector + std::vector names; + names.reserve(presets.size()); + for (const auto &entry : this->custom_preset_config_) { + names.push_back(entry.name); + } + this->set_supported_custom_presets(names); } ThermostatClimate::ThermostatClimate() = default; diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index c31ccbc7ea..7ac0abeee0 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -123,8 +123,8 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): f"Can't have more than two '/' in one time expression, got {part}" ) offset, repeat = data - offset_n = 0 - if offset: + offset_n = min_value + if offset and offset not in ("*", "?"): offset_n = _parse_cron_int( offset, special_mapping, diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index 8bc87878d1..7eb99cfe74 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -20,7 +20,12 @@ bool CronTrigger::matches(const ESPTime &time) { return time.is_valid() && this->seconds_[time.second] && this->minutes_[time.minute] && this->hours_[time.hour] && this->days_of_month_[time.day_of_month] && this->months_[time.month] && this->days_of_week_[time.day_of_week]; } -void CronTrigger::loop() { +void CronTrigger::setup() { + // Cron resolution is 1 second — check once per second instead of every loop iteration + this->set_interval(1000, [this]() { this->check_time_(); }); +} + +void CronTrigger::check_time_() { ESPTime time = this->rtc_->now(); if (!time.is_valid()) return; diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 4ccfc641d6..546c4a10de 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -26,10 +26,11 @@ class CronTrigger : public Trigger<>, public Component { void add_day_of_week(uint8_t day_of_week); void add_days_of_week(const std::vector &days_of_week); bool matches(const ESPTime &time); - void loop() override; + void setup() override; float get_setup_priority() const override; protected: + void check_time_(); std::bitset<61> seconds_; std::bitset<60> minutes_; std::bitset<24> hours_; 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/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index fb35eb21b5..7d957df3be 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -39,7 +39,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_DIO_PIN): pins.internal_gpio_output_pin_schema, } - ), + ).extend(cv.COMPONENT_SCHEMA), ) diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index e7a45a5edf..161c712cc1 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -1,10 +1,21 @@ #include "total_daily_energy.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace total_daily_energy { +namespace esphome::total_daily_energy { static const char *const TAG = "total_daily_energy"; +static constexpr uint32_t TIMEOUT_ID_MIDNIGHT = 1; +static constexpr uint8_t SECONDS_PER_MINUTE = 60; +static constexpr uint8_t MINUTES_PER_HOUR = 60; +static constexpr uint8_t HOURS_PER_DAY = 24; +static constexpr uint32_t SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; +static constexpr uint16_t MILLIS_PER_SECOND = 1000; +// Wake up 90 minutes before midnight to recalculate, ensuring DST transitions +// (which shift wall clock by 1 hour but don't change millis()) don't cause +// the midnight reset to fire late. DST transitions don't trigger the time sync +// callback since they change local time interpretation, not the epoch. +static constexpr uint32_t PRE_MIDNIGHT_SECONDS = 90 * SECONDS_PER_MINUTE; void TotalDailyEnergy::setup() { float initial_value = 0; @@ -15,28 +26,55 @@ void TotalDailyEnergy::setup() { } this->publish_state_and_save(initial_value); - this->last_update_ = millis(); + this->last_update_ = App.get_loop_component_start_time(); this->parent_->add_on_state_callback([this](float state) { this->process_new_state_(state); }); + + // Schedule initial midnight reset if time is already valid, otherwise + // the time sync callback will handle it once time becomes available. + this->schedule_midnight_reset_(); + // Re-schedule on every NTP sync in case the clock jumped across midnight. + this->time_->add_on_time_sync_callback([this]() { this->schedule_midnight_reset_(); }); } void TotalDailyEnergy::dump_config() { LOG_SENSOR("", "Total Daily Energy", this); } -void TotalDailyEnergy::loop() { +void TotalDailyEnergy::schedule_midnight_reset_() { auto t = this->time_->now(); if (!t.is_valid()) return; - if (this->last_day_of_year_ == 0) { + // Check if the day changed (time sync moved us past midnight, or first call) + if (this->last_day_of_year_ != t.day_of_year) { + if (this->last_day_of_year_ != 0) { + // Day actually changed — reset energy + this->total_energy_ = 0; + this->publish_state_and_save(0); + } this->last_day_of_year_ = t.day_of_year; - return; } - if (t.day_of_year != this->last_day_of_year_) { - this->last_day_of_year_ = t.day_of_year; - this->total_energy_ = 0; - this->publish_state_and_save(0); + // Calculate seconds until next midnight. + // Uses the same TIMEOUT_ID_MIDNIGHT ID so re-scheduling (e.g. from time sync) cancels + // any previously pending timeout. + uint32_t seconds_until_midnight = + ((HOURS_PER_DAY - 1 - t.hour) * MINUTES_PER_HOUR + (MINUTES_PER_HOUR - 1 - t.minute)) * SECONDS_PER_MINUTE + + (SECONDS_PER_MINUTE - t.second); + + // set_timeout counts real elapsed millis, but DST shifts wall clock by up to 1 hour + // without changing millis. To avoid firing up to 1 hour late/early, we use two stages: + // 1) Wake up 90 minutes before midnight to recalculate with current wall clock + // 2) From there, schedule the precise midnight reset + uint32_t timeout_seconds; + if (seconds_until_midnight > PRE_MIDNIGHT_SECONDS) { + timeout_seconds = seconds_until_midnight - PRE_MIDNIGHT_SECONDS; + } else { + timeout_seconds = seconds_until_midnight + 1; } + + ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds); + this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND, + [this]() { this->schedule_midnight_reset_(); }); } void TotalDailyEnergy::publish_state_and_save(float state) { @@ -50,14 +88,14 @@ void TotalDailyEnergy::publish_state_and_save(float state) { void TotalDailyEnergy::process_new_state_(float state) { if (std::isnan(state)) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); const float old_state = this->last_power_state_; const float new_state = state; - float delta_hours = (now - this->last_update_) / 1000.0f / 60.0f / 60.0f; + float delta_hours = (now - this->last_update_) / static_cast(MILLIS_PER_SECOND) / SECONDS_PER_HOUR; float delta_energy = 0.0f; switch (this->method_) { case TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID: - delta_energy = delta_hours * (old_state + new_state) / 2.0; + delta_energy = delta_hours * (old_state + new_state) / 2.0f; break; case TOTAL_DAILY_ENERGY_METHOD_LEFT: delta_energy = delta_hours * old_state; @@ -71,5 +109,4 @@ void TotalDailyEnergy::process_new_state_(float state) { this->publish_state_and_save(this->total_energy_ + delta_energy); } -} // namespace total_daily_energy -} // namespace esphome +} // namespace esphome::total_daily_energy diff --git a/esphome/components/total_daily_energy/total_daily_energy.h b/esphome/components/total_daily_energy/total_daily_energy.h index 1145f54f95..9a20ecea01 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.h +++ b/esphome/components/total_daily_energy/total_daily_energy.h @@ -6,8 +6,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/time/real_time_clock.h" -namespace esphome { -namespace total_daily_energy { +namespace esphome::total_daily_energy { enum TotalDailyEnergyMethod { TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID = 0, @@ -23,12 +22,12 @@ class TotalDailyEnergy : public sensor::Sensor, public Component { void set_method(TotalDailyEnergyMethod method) { method_ = method; } void setup() override; void dump_config() override; - void loop() override; void publish_state_and_save(float state); protected: void process_new_state_(float state); + void schedule_midnight_reset_(); ESPPreferenceObject pref_; time::RealTimeClock *time_; @@ -41,5 +40,4 @@ class TotalDailyEnergy : public sensor::Sensor, public Component { float last_power_state_{0.0f}; }; -} // namespace total_daily_energy -} // namespace esphome +} // namespace esphome::total_daily_energy diff --git a/esphome/components/tt21100/binary_sensor/__init__.py b/esphome/components/tt21100/binary_sensor/__init__.py index f79eff0e01..081bd17c20 100644 --- a/esphome/components/tt21100/binary_sensor/__init__.py +++ b/esphome/components/tt21100/binary_sensor/__init__.py @@ -16,11 +16,15 @@ TT21100Button = tt21100_ns.class_( cg.Parented.template(TT21100Touchscreen), ) -CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TT21100Button).extend( - { - cv.GenerateID(CONF_TT21100_ID): cv.use_id(TT21100Touchscreen), - cv.Required(CONF_INDEX): cv.int_range(min=0, max=3), - } +CONFIG_SCHEMA = ( + binary_sensor.binary_sensor_schema(TT21100Button) + .extend( + { + cv.GenerateID(CONF_TT21100_ID): cv.use_id(TT21100Touchscreen), + cv.Required(CONF_INDEX): cv.int_range(min=0, max=3), + } + ) + .extend(cv.COMPONENT_SCHEMA) ) diff --git a/esphome/components/tx20/sensor.py b/esphome/components/tx20/sensor.py index 4f1582072e..1bb5ab0706 100644 --- a/esphome/components/tx20/sensor.py +++ b/esphome/components/tx20/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_PIN, CONF_WIND_DIRECTION_DEGREES, CONF_WIND_SPEED, + DEVICE_CLASS_WIND_SPEED, ICON_SIGN_DIRECTION, ICON_WEATHER_WINDY, STATE_CLASS_MEASUREMENT, @@ -24,12 +25,14 @@ CONFIG_SCHEMA = cv.Schema( unit_of_measurement=UNIT_KILOMETER_PER_HOUR, icon=ICON_WEATHER_WINDY, accuracy_decimals=1, + device_class=DEVICE_CLASS_WIND_SPEED, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_WIND_DIRECTION_DEGREES): sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, icon=ICON_SIGN_DIRECTION, accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema), } diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 83649cc209..7075228743 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -42,16 +42,6 @@ CODEOWNERS = ["@esphome/core"] DOMAIN = "uart" -def AUTO_LOAD() -> list[str]: - """Ideally, we would only auto-load socket only when wake_loop_on_rx is requested; - however, AUTO_LOAD is examined before wake_loop_on_rx is set, so instead, since ESP32 - always uses socket select support in the main app, we'll just ensure it's loaded here. - """ - if CORE.is_esp32: - return ["socket"] - return [] - - uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") @@ -527,10 +517,6 @@ async def final_step(): # Wake-on-RX is essentially free on ESP32 (just an ISR function pointer # registration) — enable by default to reduce RX buffer overflow risk # by waking the main loop immediately when data arrives. - # Requires networking for the wake_loop_isrsafe() infrastructure. - from esphome.components import socket - - socket.require_wake_loop_threadsafe() cg.add_define("USE_UART_WAKE_LOOP_ON_RX") 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/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 10b4ece614..1d8775ccf0 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -102,8 +102,8 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - solution_ = await cg.templatable(config[CONF_SOLUTION], args, float) - temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, float) + solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) + temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) cg.add(var.set_solution(solution_)) cg.add(var.set_temperature(temperature_)) return var diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index a116012d05..23254b2f47 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -96,7 +96,7 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, float) + template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) cg.add(var.set_solution(template_)) return var @@ -110,7 +110,7 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, float) + template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) cg.add(var.set_solution(template_)) return var diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index c89d23672e..7a56654804 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -70,7 +70,7 @@ void UptimeTextSensor::update() { if (show_seconds) append_unit(buf, sizeof(buf), pos, this->separator_, seconds, this->seconds_text_); - this->publish_state(buf); + this->publish_state(buf, pos); } float UptimeTextSensor::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 253626f0a3..40f7f2e28b 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -29,10 +29,7 @@ void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->event_queue_.push(event); - -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif } void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, @@ -53,10 +50,7 @@ void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_ // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->event_queue_.push(event); - -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) App.wake_loop_threadsafe(); -#endif } void USBCDCACMInstance::process_events_() { diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 5eb0371e5c..338bd8d572 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,5 +1,4 @@ import esphome.codegen as cg -from esphome.components import socket from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -14,7 +13,7 @@ from esphome.const import CONF_DEVICES, CONF_ID from esphome.cpp_types import Component from esphome.types import ConfigType -AUTO_LOAD = ["bytebuffer", "socket"] +AUTO_LOAD = ["bytebuffer"] CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["esp32"] usb_host_ns = cg.esphome_ns.namespace("usb_host") @@ -76,11 +75,6 @@ async def to_code(config: ConfigType) -> None: max_requests = config[CONF_MAX_TRANSFER_REQUESTS] cg.add_define("USB_HOST_MAX_REQUESTS", max_requests) - # USB uses the socket wake_loop_threadsafe() mechanism to wake the main loop from USB task - # This enables low-latency (~12μs) USB event processing instead of waiting for - # select() timeout (0-16ms). The wake socket is shared across all components. - socket.require_wake_loop_threadsafe() - var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for device in config.get(CONF_DEVICES) or (): diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 18d938344c..c34c7ef67d 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -200,10 +200,8 @@ static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void * // Re-enable component loop to process the queued event client->enable_loop_soon_any_context(); - // Wake main loop immediately to process USB event instead of waiting for select() timeout -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + // Wake main loop immediately to process USB event App.wake_loop_threadsafe(); -#endif } void USBClient::setup() { usb_host_client_config_t config{.is_synchronous = false, diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 2d85723d72..0e8994a3ed 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,5 +1,4 @@ import esphome.codegen as cg -from esphome.components import socket from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent from esphome.components.usb_host import register_usb_client, usb_device_schema @@ -14,7 +13,7 @@ from esphome.const import ( ) from esphome.cpp_types import Component -AUTO_LOAD = ["uart", "usb_host", "bytebuffer", "socket"] +AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] CODEOWNERS = ["@clydebarrow"] usb_uart_ns = cg.esphome_ns.namespace("usb_uart") @@ -117,10 +116,6 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): - # Enable wake_loop_threadsafe for low-latency USB data processing - # The USB task queues data events that need immediate processing - socket.require_wake_loop_threadsafe() - for device in config: var = await register_usb_client(device) for index, channel in enumerate(device[CONF_CHANNELS]): diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 0b8589f671..30ec61fdc4 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -325,10 +325,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // Re-enable component loop to process the queued data this->enable_loop_soon_any_context(); - // Wake main loop immediately to process USB data instead of waiting for select() timeout -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + // Wake main loop immediately to process USB data App.wake_loop_threadsafe(); -#endif } // On success, restart input immediately from USB task for performance diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 0319ff50e7..1930a7ad0c 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -229,13 +229,13 @@ async def valve_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if stop_config := config.get(CONF_STOP): - template_ = await cg.templatable(stop_config, args, bool) + template_ = await cg.templatable(stop_config, args, cg.bool_) cg.add(var.set_stop(template_)) if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, float) + template_ = await cg.templatable(state_config, args, cg.float_) cg.add(var.set_position(template_)) if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, float) + template_ = await cg.templatable(position_config, args, cg.float_) cg.add(var.set_position(template_)) return var diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1dda6204fe..1daec1786d 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -286,10 +286,11 @@ void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char this->send(message, event, id, reconnect); } -void DeferredUpdateEventSourceList::loop() { +bool DeferredUpdateEventSourceList::loop() { for (DeferredUpdateEventSource *dues : *this) { dues->loop(); } + return !this->empty(); } void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type, @@ -318,6 +319,7 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer es->onDisconnect([this, es](AsyncEventSourceClient *client) { this->on_client_disconnect_(es); }); es->handleRequest(request); + ws->enable_loop_soon_any_context(); } void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource *source) { @@ -413,13 +415,24 @@ void WebServer::setup() { // doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly // getting a lot of events this->set_interval(10000, [this]() { + if (this->events_.empty()) + return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, "ping", millis(), 30000); }); } -void WebServer::loop() { this->events_.loop(); } +void WebServer::loop() { + // No SSE clients connected; stop looping until a new client connects via + // enable_loop_soon_any_context(). This is safe because: + // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() + // - deferrable_send_state early-outs when no clients are connected + // - try_send_nodefer (log, ping) iterates sessions which are empty + // - REST API handlers use defer() which runs via the Scheduler + if (!this->events_.loop()) + this->disable_loop(); +} #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { @@ -2194,7 +2207,11 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; - root[ESPHOME_F("summary")] = obj->update_info.summary; + // Truncate long changelogs — full text available via release_url + constexpr size_t max_summary_len = 256; + root[ESPHOME_F("summary")] = obj->update_info.summary.size() <= max_summary_len + ? obj->update_info.summary + : obj->update_info.summary.substr(0, max_summary_len); root[ESPHOME_F("release_url")] = obj->update_info.release_url; this->add_sorting_info_(root, obj); } diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6152dfbfd3..8e8b1de8c4 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -169,7 +169,8 @@ class DeferredUpdateEventSourceList final : public std::liston_connect_(rsp); } this->sessions_.push_back(rsp); + // Wake up WebServer::loop() to drain deferred event queues for this client. + // Safe from httpd task context via the pending_enable_loop_ flag. + this->web_server_->enable_loop_soon_any_context(); } -void AsyncEventSource::loop() { +bool AsyncEventSource::loop() { // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -504,6 +507,7 @@ void AsyncEventSource::loop() { ++i; } } + return !this->sessions_.empty(); } void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 81683e8d85..f2931fb507 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -340,7 +340,8 @@ class AsyncEventSource : public AsyncWebHandler { void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void loop(); + /// Returns true if there are sessions remaining (including pending cleanup). + bool loop(); bool empty() { return this->count() == 0; } size_t count() const { return this->sessions_.size(); } diff --git a/esphome/components/wiegand/__init__.py b/esphome/components/wiegand/__init__.py index 962ac4c373..36ec7bd43f 100644 --- a/esphome/components/wiegand/__init__.py +++ b/esphome/components/wiegand/__init__.py @@ -48,7 +48,7 @@ CONFIG_SCHEMA = cv.Schema( } ), } -) +).extend(cv.COMPONENT_SCHEMA) async def to_code(config): 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_cgpr1/binary_sensor.py b/esphome/components/xiaomi_cgpr1/binary_sensor.py index 2f71a11b28..0606c93dbe 100644 --- a/esphome/components/xiaomi_cgpr1/binary_sensor.py +++ b/esphome/components/xiaomi_cgpr1/binary_sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMELAPSE, + STATE_CLASS_MEASUREMENT, UNIT_LUX, UNIT_MINUTE, UNIT_PERCENT, @@ -38,6 +39,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0, device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), cv.Optional(CONF_IDLE_TIME): sensor.sensor_schema( @@ -45,11 +47,13 @@ CONFIG_SCHEMA = cv.All( icon=ICON_TIMELAPSE, accuracy_decimals=0, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( unit_of_measurement=UNIT_LUX, accuracy_decimals=0, device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, ), } ) diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index 4aa8d029f8..14e5c1d376 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_IMPEDANCE, CONF_MAC_ADDRESS, CONF_WEIGHT, + DEVICE_CLASS_WEIGHT, ICON_OMEGA, ICON_SCALE_BATHROOM, STATE_CLASS_MEASUREMENT, @@ -31,6 +32,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_KILOGRAM, icon=ICON_SCALE_BATHROOM, accuracy_decimals=2, + device_class=DEVICE_CLASS_WEIGHT, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_IMPEDANCE): sensor.sensor_schema( diff --git a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py index 312f8b43b1..312abc82cb 100644 --- a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py +++ b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py @@ -43,6 +43,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_MINUTE, icon=ICON_TIMELAPSE, accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, 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/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 348e7a3cf2..d3cc6b2cf4 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -199,11 +199,14 @@ def zephyr_add_user(key, value): def copy_files(): user = zephyr_data()[KEY_USER] if user: + entries = " ".join( + f"{key} = {', '.join(value)};" for key, value in user.items() + ) zephyr_add_overlay( f""" / {{ zephyr,user {{ - {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} + {entries} }}; }}; """ diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 45d2cd8117..31cfb41a6d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -75,7 +75,6 @@ from esphome.const import ( SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, - VALID_SUBSTITUTIONS_CHARACTERS, Framework, __version__ as ESPHOME_VERSION, ) @@ -90,6 +89,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) +from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG from esphome.helpers import add_class_to_obj, docs_url, list_starts_with from esphome.schema_extractors import ( SCHEMA_EXTRACT, @@ -104,11 +104,6 @@ from esphome.yaml_util import make_data_base _LOGGER = logging.getLogger(__name__) -# pylint: disable=consider-using-f-string -VARIABLE_PROG = re.compile( - f"\\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\\}})" -) - # pylint: disable=invalid-name Schema = _Schema @@ -130,6 +125,26 @@ RequiredFieldInvalid = vol.RequiredFieldInvalid # the rest of the error path is relative to the root config path ROOT_CONFIG_PATH = object() + +def ByteLength(*, max: int) -> Callable[[str], str]: + """Validate that the UTF-8 byte length of a string does not exceed max. + + Use instead of Length() when the limit must apply to encoded bytes, + not characters (e.g. for protobuf length-varint constraints). + """ + + def validator(value: str) -> str: + byte_len = len(str(value).encode("utf-8")) + if byte_len > max: + raise Invalid( + f"String is too long ({byte_len} bytes, max {max}). " + f"Multibyte characters count as multiple bytes." + ) + return value + + return validator + + RESERVED_IDS = [ # C++ keywords https://en.cppreference.com/w/cpp/keyword "alarm", @@ -244,6 +259,8 @@ RESERVED_IDS = [ "open", "setup", "loop", + "spi0", + "spi1", "uart0", "uart1", "uart2", @@ -409,9 +426,10 @@ def icon(value): raise Invalid( 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' ) - if len(value) > ICON_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) + if byte_len > ICON_MAX_LENGTH: raise Invalid( - f"Icon string is too long ({len(value)} chars, max {ICON_MAX_LENGTH}). " + f"Icon string is too long ({byte_len} bytes, max {ICON_MAX_LENGTH}). " "Icons are stored in PROGMEM with a 64-byte buffer limit." ) return value @@ -1445,17 +1463,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("%"): @@ -1467,21 +1521,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): @@ -1665,7 +1714,7 @@ def dimensions(value): match = re.match(r"\s*([0-9]+)\s*[xX]\s*([0-9]+)\s*", value) if not match: raise Invalid( - "Invalid value '{}' for dimensions. Only WIDTHxHEIGHT is allowed." + f"Invalid value '{value}' for dimensions. Only WIDTHxHEIGHT is allowed." ) return dimensions([match.group(1), match.group(2)]) @@ -2065,11 +2114,12 @@ def _validate_entity_name(value): "Name cannot be None when esphome->friendly_name is not set!" )(value) if value is not None: - # Validate length for web server URL compatibility - if len(value) > NAME_MAX_LENGTH: + # Validate byte length for web server URL and proto encoding compatibility + byte_len = len(value.encode("utf-8")) + if byte_len > NAME_MAX_LENGTH: raise Invalid( - f"Name is too long ({len(value)} chars). " - f"Maximum length is {NAME_MAX_LENGTH} characters." + f"Name is too long ({byte_len} bytes). " + f"Maximum length is {NAME_MAX_LENGTH} bytes." ) # Validate no '/' in name for web server URL compatibility value = _validate_no_slash(value) diff --git a/esphome/const.py b/esphome/const.py index 29ce030329..c2bf86d532 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.0-dev" +__version__ = "2026.5.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 5cb8a5bb24..cd75859880 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -28,22 +28,8 @@ #include "esphome/components/socket/socket.h" #endif -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_HOST #include - -#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS -// LWIP sockets implementation -#include -#elif defined(USE_SOCKET_IMPL_BSD_SOCKETS) -// BSD sockets implementation -#ifdef USE_ESP32 -// ESP32 "BSD sockets" are actually LWIP under the hood -#include -#else -// True BSD sockets (e.g., host platform) -#include -#endif -#endif #endif namespace esphome { @@ -128,13 +114,11 @@ void Application::setup() { clear_setup_priority_overrides(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT) - // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake. - // The fast path (rcvevent reads + ulTaskNotifyTake) is used unconditionally - // when USE_LWIP_FAST_SELECT is enabled (ESP32 and LibreTiny). - esphome_lwip_fast_select_init(); +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + // Save main loop task handle for wake_loop_*() / fast select FreeRTOS notifications. + esphome_main_task_handle = xTaskGetCurrentTaskHandle(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST // Set up wake socket for waking main loop from tasks (platforms without fast select only) this->setup_wake_loop_threadsafe_(); #endif @@ -490,23 +474,17 @@ void Application::unregister_socket(struct lwip_sock *sock) { return; } } -#elif defined(USE_SOCKET_SELECT_SUPPORT) +#elif defined(USE_HOST) bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking if (fd < 0) return false; -#ifndef USE_ESP32 - // Only check on non-ESP32 platforms - // On ESP32 (both Arduino and ESP-IDF), CONFIG_LWIP_MAX_SOCKETS is always <= FD_SETSIZE by design - // (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS per lwipopts.h) - // Other platforms may not have this guarantee if (fd >= FD_SETSIZE) { ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); return false; } -#endif this->socket_fds_.push_back(fd); this->socket_fds_changed_ = true; @@ -547,7 +525,7 @@ void Application::unregister_socket_fd(int fd) { #endif // Only the select() fallback path remains in the .cpp — all other paths are inlined in application.h -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST void Application::yield_with_select_(uint32_t delay_ms) { // Fallback select() path (host platform and any future platforms without fast select). if (!this->socket_fds_.empty()) [[likely]] { @@ -570,11 +548,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; // Call select with timeout -#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS - int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); -#else int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); -#endif // Process select() result: // ret > 0: socket(s) have data ready - normal and expected @@ -597,7 +571,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { // No sockets registered or select() failed - use regular delay delay(delay_ms); } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#endif // USE_HOST // App storage — asm label shares the linker symbol with "extern Application App". // char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted. @@ -618,18 +592,13 @@ alignas(Application) char app_storage[sizeof(Application)] asm( #undef ESPHOME_STRINGIFY_ #undef ESPHOME_STRINGIFY_IMPL_ -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - -#ifdef USE_LWIP_FAST_SELECT -void Application::wake_loop_threadsafe() { - // Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe) - esphome_lwip_wake_main_loop(); -} -#else // !USE_LWIP_FAST_SELECT +// Host platform wake_loop_threadsafe() and setup — needs wake_socket_fd_ +// ESP32/LibreTiny/ESP8266/RP2040 implementations are in wake.cpp +#ifdef USE_HOST void Application::setup_wake_loop_threadsafe_() { // Create UDP socket for wake notifications - this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + this->wake_socket_fd_ = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (this->wake_socket_fd_ < 0) { ESP_LOGW(TAG, "Wake socket create failed: %d", errno); return; @@ -638,12 +607,12 @@ void Application::setup_wake_loop_threadsafe_() { // Bind to loopback with auto-assigned port struct sockaddr_in addr = {}; addr.sin_family = AF_INET; - addr.sin_addr.s_addr = lwip_htonl(INADDR_LOOPBACK); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = 0; // Auto-assign port - if (lwip_bind(this->wake_socket_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + if (::bind(this->wake_socket_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); - lwip_close(this->wake_socket_fd_); + ::close(this->wake_socket_fd_); this->wake_socket_fd_ = -1; return; } @@ -652,50 +621,36 @@ void Application::setup_wake_loop_threadsafe_() { // Connecting a UDP socket allows using send() instead of sendto() for better performance struct sockaddr_in wake_addr; socklen_t len = sizeof(wake_addr); - if (lwip_getsockname(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, &len) < 0) { + if (::getsockname(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, &len) < 0) { ESP_LOGW(TAG, "Wake socket address failed: %d", errno); - lwip_close(this->wake_socket_fd_); + ::close(this->wake_socket_fd_); this->wake_socket_fd_ = -1; return; } // Connect to self (loopback) - allows using send() instead of sendto() // After connect(), no need to store wake_addr - the socket remembers it - if (lwip_connect(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { + if (::connect(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); - lwip_close(this->wake_socket_fd_); + ::close(this->wake_socket_fd_); this->wake_socket_fd_ = -1; return; } // Set non-blocking mode - int flags = lwip_fcntl(this->wake_socket_fd_, F_GETFL, 0); - lwip_fcntl(this->wake_socket_fd_, F_SETFL, flags | O_NONBLOCK); + int flags = ::fcntl(this->wake_socket_fd_, F_GETFL, 0); + ::fcntl(this->wake_socket_fd_, F_SETFL, flags | O_NONBLOCK); // Register with application's select() loop if (!this->register_socket_fd(this->wake_socket_fd_)) { ESP_LOGW(TAG, "Wake socket register failed"); - lwip_close(this->wake_socket_fd_); + ::close(this->wake_socket_fd_); this->wake_socket_fd_ = -1; return; } } -void Application::wake_loop_threadsafe() { - // Called from FreeRTOS task context when events need immediate processing - // Wakes up lwip_select() in main loop by writing to connected loopback socket - if (this->wake_socket_fd_ >= 0) { - const char dummy = 1; - // Non-blocking send - if it fails (unlikely), select() will wake on timeout anyway - // No error checking needed: we control both ends of this loopback socket. - // This is safe to call from FreeRTOS tasks - send() is thread-safe in lwip - // Socket is already connected to loopback address, so send() is faster than sendto() - lwip_send(this->wake_socket_fd_, &dummy, 1, 0); - } -} -#endif // USE_LWIP_FAST_SELECT - -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#endif // USE_HOST void Application::get_build_time_string(std::span buffer) { ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6cc61bc954..6b2969b490 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,32 +24,21 @@ #include "esphome/core/area.h" #endif -#ifdef USE_SOCKET_SELECT_SUPPORT #ifdef USE_LWIP_FAST_SELECT #include "esphome/core/lwip_fast_select.h" -#ifdef USE_ESP32 -#include -#include -#else -#include -#include #endif -#else +#ifdef USE_HOST #include -#ifdef USE_WAKE_LOOP_THREADSAFE -#include +#include +#include +#include +#include +#include #endif -#endif -#endif // USE_SOCKET_SELECT_SUPPORT #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) -namespace esphome::socket { -void socket_wake(); // NOLINT(readability-redundant-declaration) -void socket_delay(uint32_t ms); // NOLINT(readability-redundant-declaration) -} // namespace esphome::socket -#endif +#include "esphome/core/wake.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -124,7 +113,7 @@ void socket_delay(uint32_t ms); // NOLINT(readability-redundant-declaration) #endif namespace esphome::socket { -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_HOST /// Shared ready() helper for fd-based socket implementations. bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redundant-declaration) #endif @@ -550,7 +539,7 @@ class Application { /// @return true if registration was successful, false if sock is null bool register_socket(struct lwip_sock *sock); void unregister_socket(struct lwip_sock *sock); -#elif defined(USE_SOCKET_SELECT_SUPPORT) +#elif defined(USE_HOST) /// Fallback select() path: monitors file descriptors. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. /// @return true if registration was successful, false if fd exceeds limits @@ -558,43 +547,21 @@ class Application { void unregister_socket_fd(int fd); #endif -#ifdef USE_WAKE_LOOP_THREADSAFE - /// Wake the main event loop from another FreeRTOS task. - /// Thread-safe, but must only be called from task context (NOT ISR-safe). - /// On ESP32: uses xTaskNotifyGive (<1 us) - /// On other platforms: uses UDP loopback socket - void wake_loop_threadsafe(); -#endif - -#ifdef USE_LWIP_FAST_SELECT - /// Wake the main event loop from an ISR. - /// Uses vTaskNotifyGiveFromISR() — <1 us, ISR-safe. - /// Only available on platforms with fast select (ESP32, LibreTiny). - /// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. - static void IRAM_ATTR wake_loop_isrsafe(int *px_higher_priority_task_woken) { - esphome_lwip_wake_main_loop_from_isr(px_higher_priority_task_woken); - } + /// Wake the main event loop from another thread or callback. + /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. + void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } #ifdef USE_ESP32 - /// Wake the main event loop from any context (ISR, thread, or main loop). - /// Detects the calling context and uses the appropriate FreeRTOS API. - static void IRAM_ATTR wake_loop_any_context() { esphome_lwip_wake_main_loop_any_context(); } -#endif + /// Wake from ISR (ESP32 only). + static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } #endif -#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) - /// Wake the main event loop from any context (ISR, thread, or main loop). - /// Sets the socket wake flag and calls esp_schedule() to exit esp_delay() early. - static void IRAM_ATTR wake_loop_any_context() { socket::socket_wake(); } -#elif defined(USE_RP2040) && defined(USE_SOCKET_IMPL_LWIP_TCP) - /// Wake the main event loop from any context. - /// Sets the socket wake flag and calls __sev() to exit __wfe() early. - static void wake_loop_any_context() { socket::socket_wake(); } -#endif + /// Wake from any context (ISR, thread, callback). + static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } protected: friend Component; -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST friend bool socket::socket_ready_fd(int fd, bool loop_monitored); #endif #ifdef USE_RUNTIME_STATS @@ -602,8 +569,11 @@ class Application { #endif friend void ::setup(); friend void ::original_setup(); +#ifdef USE_HOST + friend void wake_loop_threadsafe(); // Host platform accesses wake_socket_fd_ +#endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } #endif @@ -648,14 +618,14 @@ class Application { void feed_wdt_arch_(); /// Perform a delay while also monitoring socket file descriptors for readiness -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST // select() fallback path is too complex to inline (host platform) void yield_with_select_(uint32_t delay_ms); #else inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST void setup_wake_loop_threadsafe_(); // Create wake notification socket inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif @@ -685,13 +655,11 @@ class Application { FixedVector looping_components_{}; #ifdef USE_LWIP_FAST_SELECT std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read -#elif defined(USE_SOCKET_SELECT_SUPPORT) +#elif defined(USE_HOST) std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif -#ifdef USE_SOCKET_SELECT_SUPPORT -#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks -#endif #endif // StringRef members (8 bytes each: pointer + size) @@ -702,7 +670,7 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST int max_fd_{-1}; // Highest file descriptor number for select() #endif @@ -718,11 +686,11 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST // Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly) fd_set read_fds_{}; // Working fd_set: populated by select() fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes @@ -815,7 +783,7 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST // Inline implementations for hot-path functions // drain_wake_notifications_() is called on every loop iteration @@ -832,15 +800,15 @@ inline void Application::drain_wake_notifications_() { // Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK // We control both ends of this loopback socket (always write 1 byte per wake), // so no error checking needed - any errors indicate catastrophic system failure - while (lwip_recvfrom(this->wake_socket_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + while (::recvfrom(this->wake_socket_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { // Just draining, no action needed - wake has already occurred } } } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) +#endif // USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) +#ifdef USE_HOST // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif @@ -908,21 +876,17 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #endif // Use the last component's end time instead of calling millis() again + uint32_t delay_time = 0; auto elapsed = last_op_end_time - this->last_loop_; - if (elapsed >= this->loop_interval_ || HighFrequencyLoopRequester::is_high_frequency()) { - // Even if we overran the loop interval, we still need to select() - // to know if any sockets have data ready - this->yield_with_select_(0); - } else { - uint32_t delay_time = this->loop_interval_ - elapsed; + if (elapsed < this->loop_interval_ && !HighFrequencyLoopRequester::is_high_frequency()) { + delay_time = this->loop_interval_ - elapsed; uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time); // next_schedule is max 0.5*delay_time // otherwise interval=0 schedules result in constant looping with almost no sleep next_schedule = std::max(next_schedule, delay_time / 2); delay_time = std::min(next_schedule, delay_time); - - this->yield_with_select_(delay_time); } + this->yield_with_select_(delay_time); this->last_loop_ = last_op_end_time; if (this->dump_config_at_ < this->components_.size()) { @@ -931,9 +895,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } // Inline yield_with_select_ for all paths except the select() fallback -#if !defined(USE_SOCKET_SELECT_SUPPORT) || defined(USE_LWIP_FAST_SELECT) +#ifndef USE_HOST inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT) +#ifdef USE_LWIP_FAST_SELECT // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. // Safe because this runs on the main loop which owns socket lifetime (create, read, close). if (delay_ms == 0) [[unlikely]] { @@ -953,20 +917,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay } // Sleep with instant wake via FreeRTOS task notification. - // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. - // Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task — - // background tasks won't call wake, so this degrades to a pure timeout (same as old select path). - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); -#elif (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) - // No select support but can wake on socket activity - // ESP8266: via esp_schedule() - // RP2040: via __sev()/__wfe() hardware sleep/wake - socket::socket_delay(delay_ms); -#else - // No select support, use regular delay - delay(delay_ms); + // Woken by: callback wrapper (socket data), wake_loop_threadsafe() (background tasks), or timeout. #endif + esphome::internal::wakeable_delay(delay_ms); } -#endif // !defined(USE_SOCKET_SELECT_SUPPORT) || defined(USE_LWIP_FAST_SELECT) +#endif // !USE_HOST } // namespace esphome diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 05c7f19588..eb270bfee2 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -34,70 +34,236 @@ template struct gens<0, S...> { using type = seq; }; #endif // NOLINTEND(readability-identifier-naming) +/// Function-pointer-only templatable storage (4 bytes on 32-bit). +/// Used by the TEMPLATABLE_VALUE macro for codegen-managed fields. +/// Codegen wraps constants in stateless lambdas so only a function pointer is needed. +template class TemplatableFn { + public: + TemplatableFn() = default; + TemplatableFn(std::nullptr_t) = delete; + + // Exact return type match — direct function pointer storage + template TemplatableFn(F f) requires std::convertible_to : f_(f) {} + + // Convertible return type (e.g., int -> uint8_t) — casting trampoline. + // Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside + // the trampoline without capturing. This compiles to the same code as a direct call + cast. + // Deprecated: codegen should use the correct output type to avoid the trampoline. + template + [[deprecated("Lambda return type does not match TemplatableFn — use the correct type in " + "codegen")]] TemplatableFn(F) requires(!std::convertible_to) && + std::invocable &&std::convertible_to, T> &&std::is_empty_v + &&std::default_initializable : f_([](X... x) -> T { return static_cast(F{}(x...)); }) {} + + // Reject any callable that didn't match the above (stateful lambdas or inconvertible return types) + template + TemplatableFn(F) requires std::invocable && + (!std::convertible_to) &&(!std::is_empty_v || + !std::convertible_to, T> || + !std::default_initializable) = delete; + + bool has_value() const { return this->f_ != nullptr; } + + T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } + + optional optional_value(X... x) const { + if (!this->f_) + return {}; + return this->f_(x...); + } + + T value_or(X... x, T default_value) const { return this->f_ ? this->f_(x...) : default_value; } + + protected: + T (*f_)(X...){nullptr}; +}; + +// Forward declaration for TemplatableValue (string specialization needs it) +template class TemplatableValue; + +/// Selects TemplatableFn (4 bytes) for trivially copyable types, TemplatableValue (8 bytes) otherwise. +/// Non-trivial types (std::string, std::vector, etc.) need TemplatableValue for raw value +/// storage, PROGMEM/FlashStringHelper support (strings), and proper copy/move/destruction. +template +using TemplatableStorage = + std::conditional_t, TemplatableFn, TemplatableValue>; + #define TEMPLATABLE_VALUE_(type, name) \ protected: \ - TemplatableValue name##_{}; \ + TemplatableStorage name##_{}; \ \ public: \ template void set_##name(V name) { this->name##_ = name; } #define TEMPLATABLE_VALUE(type, name) TEMPLATABLE_VALUE_(type, name) +/// Primary TemplatableValue: stores either a constant value or a function pointer. +/// No std::function, no string-specific paths. 8 bytes on 32-bit. +/// Accepts raw constants for backward compatibility with direct C++ usage. template class TemplatableValue { - // For std::string, store pointer to heap-allocated string to keep union pointer-sized. - // For other types, store value inline. - static constexpr bool USE_HEAP_STORAGE = std::same_as; + public: + TemplatableValue() = default; + TemplatableValue(std::nullptr_t) = delete; + // Accept raw constants + template TemplatableValue(V value) requires(!std::invocable) : tag_(VALUE) { + new (&this->storage_.value_) T(static_cast(std::move(value))); + } + + // Accept stateless lambdas (convertible to function pointer) + template TemplatableValue(F f) requires std::convertible_to : tag_(FN) { + this->storage_.f_ = f; + } + + // Convertible return type (e.g., int -> uint8_t) — casting trampoline + template + [[deprecated("Lambda return type does not match TemplatableValue — use the correct type in " + "codegen")]] TemplatableValue(F) requires(!std::convertible_to) && + std::invocable &&std::convertible_to, T> &&std::is_empty_v + &&std::default_initializable : tag_(FN) { + this->storage_.f_ = [](X... x) -> T { return static_cast(F{}(x...)); }; + } + + // Reject any callable that didn't match the above + template + TemplatableValue(F) requires std::invocable && + (!std::convertible_to) &&(!std::is_empty_v || + !std::convertible_to, T> || + !std::default_initializable) = delete; + + TemplatableValue(const TemplatableValue &other) : tag_(other.tag_) { + if (this->tag_ == VALUE) { + new (&this->storage_.value_) T(other.storage_.value_); + } else if (this->tag_ == FN) { + this->storage_.f_ = other.storage_.f_; + } + } + + TemplatableValue(TemplatableValue &&other) noexcept : tag_(other.tag_) { + if (this->tag_ == VALUE) { + new (&this->storage_.value_) T(std::move(other.storage_.value_)); + other.destroy_(); + } else if (this->tag_ == FN) { + this->storage_.f_ = other.storage_.f_; + } + other.tag_ = NONE; + } + + TemplatableValue &operator=(const TemplatableValue &other) { + if (this != &other) { + this->destroy_(); + this->tag_ = other.tag_; + if (this->tag_ == VALUE) { + new (&this->storage_.value_) T(other.storage_.value_); + } else if (this->tag_ == FN) { + this->storage_.f_ = other.storage_.f_; + } + } + return *this; + } + + TemplatableValue &operator=(TemplatableValue &&other) noexcept { + if (this != &other) { + this->destroy_(); + this->tag_ = other.tag_; + if (this->tag_ == VALUE) { + new (&this->storage_.value_) T(std::move(other.storage_.value_)); + other.destroy_(); + } else if (this->tag_ == FN) { + this->storage_.f_ = other.storage_.f_; + } + other.tag_ = NONE; + } + return *this; + } + + ~TemplatableValue() { this->destroy_(); } + + bool has_value() const { return this->tag_ != NONE; } + + T value(X... x) const { + if (this->tag_ == FN) + return this->storage_.f_(x...); + if (this->tag_ == VALUE) + return this->storage_.value_; + return T{}; + } + + optional optional_value(X... x) const { + if (this->tag_ == NONE) + return {}; + return this->value(x...); + } + + T value_or(X... x, T default_value) const { + if (this->tag_ == NONE) + return default_value; + return this->value(x...); + } + + protected: + void destroy_() { + if constexpr (!std::is_trivially_destructible_v) { + if (this->tag_ == VALUE) + this->storage_.value_.~T(); + } + } + + enum Tag : uint8_t { NONE, VALUE, FN } tag_{NONE}; + // Union with explicit ctor/dtor to support non-trivially-constructible/destructible T + // (e.g., std::vector). Lifetime of value_ is managed externally via + // placement new and destroy_(). + union Storage { + constexpr Storage() : f_(nullptr) {} + constexpr ~Storage() {} + T value_; + T (*f_)(X...); + } storage_; +}; + +/// Specialization for std::string: supports VALUE, STATIC_STRING, FLASH_STRING, +/// stateless lambdas, and stateful lambdas (std::function). +template class TemplatableValue { public: TemplatableValue() : type_(NONE) {} - // For const char* when T is std::string: store pointer directly, no heap allocation - // String remains in flash and is only converted to std::string when value() is called - TemplatableValue(const char *str) requires std::same_as : type_(STATIC_STRING) { - this->static_str_ = str; - } + // For const char*: store pointer directly, no heap allocation. + // String remains in flash and is only converted to std::string when value() is called. + TemplatableValue(const char *str) : type_(STATIC_STRING) { this->static_str_ = str; } #ifdef USE_ESP8266 // On ESP8266, __FlashStringHelper* is a distinct type from const char*. // ESPHOME_F(s) expands to F(s) which returns __FlashStringHelper* pointing to PROGMEM. - // Store as FLASH_STRING — value()/is_empty()/ref_or_copy_to() use _P functions - // to access the PROGMEM pointer safely. - TemplatableValue(const __FlashStringHelper *str) requires std::same_as : type_(FLASH_STRING) { + // Store as FLASH_STRING — value()/is_empty()/ref_or_copy_to() use _P functions. + TemplatableValue(const __FlashStringHelper *str) : type_(FLASH_STRING) { this->static_str_ = reinterpret_cast(str); } #endif template TemplatableValue(F value) requires(!std::invocable) : type_(VALUE) { - if constexpr (USE_HEAP_STORAGE) { - this->value_ = new T(std::move(value)); - } else { - new (&this->value_) T(std::move(value)); - } + this->value_ = new std::string(std::move(value)); } // For stateless lambdas (convertible to function pointer): use function pointer template - TemplatableValue(F f) requires std::invocable && std::convertible_to + TemplatableValue(F f) requires std::invocable && std::convertible_to : type_(STATELESS_LAMBDA) { this->stateless_f_ = f; // Implicit conversion to function pointer } // For stateful lambdas (not convertible to function pointer): use std::function template - TemplatableValue(F f) requires std::invocable &&(!std::convertible_to) : type_(LAMBDA) { - this->f_ = new std::function(std::move(f)); + TemplatableValue(F f) requires std::invocable &&(!std::convertible_to) + : type_(LAMBDA) { + this->f_ = new std::function(std::move(f)); } // Copy constructor TemplatableValue(const TemplatableValue &other) : type_(other.type_) { if (this->type_ == VALUE) { - if constexpr (USE_HEAP_STORAGE) { - this->value_ = new T(*other.value_); - } else { - new (&this->value_) T(other.value_); - } + this->value_ = new std::string(*other.value_); } else if (this->type_ == LAMBDA) { - this->f_ = new std::function(*other.f_); + this->f_ = new std::function(*other.f_); } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; } else if (this->type_ == STATIC_STRING || this->type_ == FLASH_STRING) { @@ -108,12 +274,8 @@ template class TemplatableValue { // Move constructor TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) { if (this->type_ == VALUE) { - if constexpr (USE_HEAP_STORAGE) { - this->value_ = other.value_; - other.value_ = nullptr; - } else { - new (&this->value_) T(std::move(other.value_)); - } + this->value_ = other.value_; + other.value_ = nullptr; } else if (this->type_ == LAMBDA) { this->f_ = other.f_; other.f_ = nullptr; @@ -144,11 +306,7 @@ template class TemplatableValue { ~TemplatableValue() { if (this->type_ == VALUE) { - if constexpr (USE_HEAP_STORAGE) { - delete this->value_; - } else { - this->value_.~T(); - } + delete this->value_; } else if (this->type_ == LAMBDA) { delete this->f_; } @@ -157,53 +315,40 @@ template class TemplatableValue { bool has_value() const { return this->type_ != NONE; } - T value(X... x) const { + std::string value(X... x) const { switch (this->type_) { case STATELESS_LAMBDA: return this->stateless_f_(x...); // Direct function pointer call case LAMBDA: return (*this->f_)(x...); // std::function call case VALUE: - if constexpr (USE_HEAP_STORAGE) { - return *this->value_; - } else { - return this->value_; - } + return *this->value_; case STATIC_STRING: - // if constexpr required: code must compile for all T, but STATIC_STRING - // can only be set when T is std::string (enforced by constructor constraint) - if constexpr (std::same_as) { - return std::string(this->static_str_); - } - __builtin_unreachable(); + return std::string(this->static_str_); #ifdef USE_ESP8266 - case FLASH_STRING: + case FLASH_STRING: { // PROGMEM pointer — must use _P functions to access on ESP8266 - if constexpr (std::same_as) { - size_t len = strlen_P(this->static_str_); - std::string result(len, '\0'); - memcpy_P(result.data(), this->static_str_, len); - return result; - } - __builtin_unreachable(); + size_t len = strlen_P(this->static_str_); + std::string result(len, '\0'); + memcpy_P(result.data(), this->static_str_, len); + return result; + } #endif case NONE: default: - return T{}; + return {}; } } - optional optional_value(X... x) { - if (!this->has_value()) { + optional optional_value(X... x) const { + if (!this->has_value()) return {}; - } return this->value(x...); } - T value_or(X... x, T default_value) { - if (!this->has_value()) { + std::string value_or(X... x, std::string default_value) const { + if (!this->has_value()) return default_value; - } return this->value(x...); } @@ -216,10 +361,10 @@ template class TemplatableValue { /// The pointer is always directly readable — FLASH_STRING uses a separate type. const char *get_static_string() const { return this->static_str_; } - /// Check if the string value is empty without allocating (for std::string specialization). + /// Check if the string value is empty without allocating. /// For NONE, returns true. For STATIC_STRING/VALUE, checks without allocation. /// For LAMBDA/STATELESS_LAMBDA, must call value() which may allocate. - bool is_empty() const requires std::same_as { + bool is_empty() const { switch (this->type_) { case NONE: return true; @@ -245,7 +390,7 @@ template class TemplatableValue { /// @param lambda_buf Buffer used only for copy cases (must remain valid while StringRef is used). /// @param lambda_buf_size Size of the buffer. /// @return StringRef pointing to the string data. - StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const requires std::same_as { + StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const { switch (this->type_) { case NONE: return StringRef(); @@ -278,22 +423,20 @@ template class TemplatableValue { } } - protected : enum : uint8_t { - NONE, - VALUE, - LAMBDA, - STATELESS_LAMBDA, - STATIC_STRING, // For const char* when T is std::string - avoids heap allocation - FLASH_STRING, // PROGMEM pointer on ESP8266; never set on other platforms - } type_; - // For std::string, use heap pointer to minimize union size (4 bytes vs 12+). - // For other types, store value inline as before. - using ValueStorage = std::conditional_t; + protected: + enum : uint8_t { + NONE, + VALUE, + LAMBDA, + STATELESS_LAMBDA, + STATIC_STRING, // For const char* — avoids heap allocation + FLASH_STRING, // PROGMEM pointer on ESP8266; never set on other platforms + } type_; union { - ValueStorage value_; // T for inline storage, T* for heap storage - std::function *f_; - T (*stateless_f_)(X...); - const char *static_str_; // For STATIC_STRING and FLASH_STRING types + std::string *value_; // Heap-allocated string (VALUE) + std::function *f_; // Heap-allocated std::function (LAMBDA) + std::string (*stateless_f_)(X...); // Function pointer (STATELESS_LAMBDA) + const char *static_str_; // For STATIC_STRING and FLASH_STRING types }; }; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2b5aba2a7b..deda42b0a7 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -294,14 +294,12 @@ void Component::disable_loop() { App.disable_component_loop_(this); } } -void Component::enable_loop() { - if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - ESP_LOGVV(TAG, "%s loop enabled", LOG_STR_ARG(this->get_component_log_str())); - this->set_component_state_(COMPONENT_STATE_LOOP); - App.enable_component_loop_(this); - } +void Component::enable_loop_slow_path_() { + ESP_LOGVV(TAG, "%s loop enabled", LOG_STR_ARG(this->get_component_log_str())); + this->set_component_state_(COMPONENT_STATE_LOOP); + App.enable_component_loop_(this); } -void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { +void IRAM_ATTR Component::enable_loop_soon_any_context() { // This method is thread and ISR-safe because: // 1. Only performs simple assignments to volatile variables (atomic on all platforms) // 2. No read-modify-write operations that could be interrupted @@ -313,15 +311,9 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { // 8. Race condition with main loop is handled by clearing flag before processing this->pending_enable_loop_ = true; App.has_pending_enable_loop_requests_ = true; -#if (defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32)) || \ - ((defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP)) // Wake the main loop from sleep. Without this, the main loop would not // wake until the select/delay timeout expires (~16ms). - // ESP32: uses xPortInIsrContext() to choose the correct FreeRTOS notify API. - // ESP8266: sets socket wake flag and calls esp_schedule() to exit esp_delay() early. - // RP2040: sets socket wake flag and calls __sev() to exit __wfe() early. - Application::wake_loop_any_context(); -#endif + wake_loop_any_context(); } void Component::reset_to_construction_state() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { diff --git a/esphome/core/component.h b/esphome/core/component.h index f091f9434c..e2b7aa85d3 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -242,7 +242,10 @@ class Component { * @note Components should call this->enable_loop() on themselves, not on other components. * This ensures the component's state is properly updated along with the loop partition. */ - void enable_loop(); + void enable_loop() { + if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) + this->enable_loop_slow_path_(); + } /** Thread and ISR-safe version of enable_loop() that can be called from any context. * @@ -344,6 +347,8 @@ class Component { virtual void call_setup(); void call_dump_config_(); + void enable_loop_slow_path_(); + /// Helper to set component state (clears state bits and sets new state) inline void set_component_state_(uint8_t state) { this->component_state_ &= ~COMPONENT_STATE_MASK; diff --git a/esphome/core/config.py b/esphome/core/config.py index c47693c783..bf210876df 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -233,11 +233,20 @@ DEVICE_CLASS_MAX_LENGTH = 47 # Keep in sync with MAX_ICON_LENGTH in esphome/core/entity_base.h ICON_MAX_LENGTH = 63 +# Max unit of measurement string length +UNIT_OF_MEASUREMENT_MAX_LENGTH = 63 + +# Max project name/version string length (must fit in single-byte varint for proto encoding) +PROJECT_MAX_LENGTH = 127 + +# Max board/model string length (must fit in single-byte varint for proto encoding) +BOARD_MAX_LENGTH = 127 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), cv.Required(CONF_NAME): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), } ) @@ -246,7 +255,7 @@ DEVICE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), cv.Required(CONF_NAME): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA_ID): cv.use_id(Area), } @@ -263,7 +272,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_NAME): cv.valid_name, # Keep max=120 in sync with OBJECT_ID_MAX_LEN in esphome/core/entity_base.h cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All( - cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), @@ -303,9 +312,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( - cv.string_strict, valid_project_name + cv.string_strict, + valid_project_name, + cv.ByteLength(max=PROJECT_MAX_LENGTH), + ), + cv.Required(CONF_VERSION): cv.All( + cv.string_strict, cv.ByteLength(max=PROJECT_MAX_LENGTH) ), - cv.Required(CONF_VERSION): cv.string_strict, cv.Optional(CONF_ON_UPDATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( @@ -753,6 +766,20 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, + "main_task.c": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "lwip_fast_select.c": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, "time_64.cpp": { PlatformFramework.ESP8266_ARDUINO, PlatformFramework.BK72XX_ARDUINO, diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index dd69de47d4..92f23f5642 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -2,122 +2,12 @@ #ifdef USE_CONTROLLER_REGISTRY -#include "esphome/core/controller.h" - namespace esphome { StaticVector ControllerRegistry::controllers; void ControllerRegistry::register_controller(Controller *controller) { controllers.push_back(controller); } -// Each notify method directly iterates controllers and calls the virtual method. -// This avoids the overhead of a shared noinline dispatch loop with function pointer -// indirection. The loop is tiny (~20 bytes per entity type) so the flash cost of -// duplicating it is negligible compared to eliminating two levels of indirection -// (noinline call + function pointer) from every state publish. -// NOLINTBEGIN(bugprone-macro-parentheses) -#define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ - void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##entity_name##_update(obj); \ - } \ - } - -#define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ - void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##entity_name(obj); \ - } \ - } -// NOLINTEND(bugprone-macro-parentheses) - -#ifdef USE_BINARY_SENSOR -CONTROLLER_REGISTRY_NOTIFY(binary_sensor::BinarySensor, binary_sensor) -#endif - -#ifdef USE_FAN -CONTROLLER_REGISTRY_NOTIFY(fan::Fan, fan) -#endif - -#ifdef USE_LIGHT -CONTROLLER_REGISTRY_NOTIFY(light::LightState, light) -#endif - -#ifdef USE_SENSOR -CONTROLLER_REGISTRY_NOTIFY(sensor::Sensor, sensor) -#endif - -#ifdef USE_SWITCH -CONTROLLER_REGISTRY_NOTIFY(switch_::Switch, switch) -#endif - -#ifdef USE_COVER -CONTROLLER_REGISTRY_NOTIFY(cover::Cover, cover) -#endif - -#ifdef USE_TEXT_SENSOR -CONTROLLER_REGISTRY_NOTIFY(text_sensor::TextSensor, text_sensor) -#endif - -#ifdef USE_CLIMATE -CONTROLLER_REGISTRY_NOTIFY(climate::Climate, climate) -#endif - -#ifdef USE_NUMBER -CONTROLLER_REGISTRY_NOTIFY(number::Number, number) -#endif - -#ifdef USE_DATETIME_DATE -CONTROLLER_REGISTRY_NOTIFY(datetime::DateEntity, date) -#endif - -#ifdef USE_DATETIME_TIME -CONTROLLER_REGISTRY_NOTIFY(datetime::TimeEntity, time) -#endif - -#ifdef USE_DATETIME_DATETIME -CONTROLLER_REGISTRY_NOTIFY(datetime::DateTimeEntity, datetime) -#endif - -#ifdef USE_TEXT -CONTROLLER_REGISTRY_NOTIFY(text::Text, text) -#endif - -#ifdef USE_SELECT -CONTROLLER_REGISTRY_NOTIFY(select::Select, select) -#endif - -#ifdef USE_LOCK -CONTROLLER_REGISTRY_NOTIFY(lock::Lock, lock) -#endif - -#ifdef USE_VALVE -CONTROLLER_REGISTRY_NOTIFY(valve::Valve, valve) -#endif - -#ifdef USE_MEDIA_PLAYER -CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) -#endif - -#ifdef USE_WATER_HEATER -CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) -#endif - -#ifdef USE_EVENT -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) -#endif - -#ifdef USE_UPDATE -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(update::UpdateEntity, update) -#endif - -#undef CONTROLLER_REGISTRY_NOTIFY -#undef CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 89b3069bcb..846642da29 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -252,4 +252,121 @@ class ControllerRegistry { } // namespace esphome +// Include controller.h AFTER the class definition so notify methods can be +// defined inline. This is safe because controller_registry.h is only ever +// included from .cpp files, never from other headers. +#include "esphome/core/controller.h" + +namespace esphome { + +// Inline notify methods — each is a tiny loop over 1-2 controllers. +// Defining them here (rather than in controller_registry.cpp) allows the +// compiler to inline them into the single call site in each entity's +// notify_frontend_(), eliminating an unnecessary function-call frame. + +// NOLINTBEGIN(bugprone-macro-parentheses) +#define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ + inline void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ + for (auto *controller : controllers) { \ + controller->on_##entity_name##_update(obj); \ + } \ + } + +#define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ + inline void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ + for (auto *controller : controllers) { \ + controller->on_##entity_name(obj); \ + } \ + } +// NOLINTEND(bugprone-macro-parentheses) + +#ifdef USE_BINARY_SENSOR +CONTROLLER_REGISTRY_NOTIFY(binary_sensor::BinarySensor, binary_sensor) +#endif + +#ifdef USE_FAN +CONTROLLER_REGISTRY_NOTIFY(fan::Fan, fan) +#endif + +#ifdef USE_LIGHT +CONTROLLER_REGISTRY_NOTIFY(light::LightState, light) +#endif + +#ifdef USE_SENSOR +CONTROLLER_REGISTRY_NOTIFY(sensor::Sensor, sensor) +#endif + +#ifdef USE_SWITCH +CONTROLLER_REGISTRY_NOTIFY(switch_::Switch, switch) +#endif + +#ifdef USE_COVER +CONTROLLER_REGISTRY_NOTIFY(cover::Cover, cover) +#endif + +#ifdef USE_TEXT_SENSOR +CONTROLLER_REGISTRY_NOTIFY(text_sensor::TextSensor, text_sensor) +#endif + +#ifdef USE_CLIMATE +CONTROLLER_REGISTRY_NOTIFY(climate::Climate, climate) +#endif + +#ifdef USE_NUMBER +CONTROLLER_REGISTRY_NOTIFY(number::Number, number) +#endif + +#ifdef USE_DATETIME_DATE +CONTROLLER_REGISTRY_NOTIFY(datetime::DateEntity, date) +#endif + +#ifdef USE_DATETIME_TIME +CONTROLLER_REGISTRY_NOTIFY(datetime::TimeEntity, time) +#endif + +#ifdef USE_DATETIME_DATETIME +CONTROLLER_REGISTRY_NOTIFY(datetime::DateTimeEntity, datetime) +#endif + +#ifdef USE_TEXT +CONTROLLER_REGISTRY_NOTIFY(text::Text, text) +#endif + +#ifdef USE_SELECT +CONTROLLER_REGISTRY_NOTIFY(select::Select, select) +#endif + +#ifdef USE_LOCK +CONTROLLER_REGISTRY_NOTIFY(lock::Lock, lock) +#endif + +#ifdef USE_VALVE +CONTROLLER_REGISTRY_NOTIFY(valve::Valve, valve) +#endif + +#ifdef USE_MEDIA_PLAYER +CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) +#endif + +#ifdef USE_ALARM_CONTROL_PANEL +CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) +#endif + +#ifdef USE_WATER_HEATER +CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) +#endif + +#ifdef USE_EVENT +CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) +#endif + +#ifdef USE_UPDATE +CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(update::UpdateEntity, update) +#endif + +#undef CONTROLLER_REGISTRY_NOTIFY +#undef CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX + +} // namespace esphome + #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 23e65f55bc..d8b4faced9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,6 +123,11 @@ #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 #define USE_POWER_SUPPLY @@ -210,6 +215,7 @@ #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK +#define USE_OTA_SIGNED_VERIFICATION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_SRAM1_AS_IRAM @@ -250,9 +256,8 @@ #define USE_SENDSPIN #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS -#define USE_SOCKET_SELECT_SUPPORT #define USE_LWIP_FAST_SELECT -#define USE_WAKE_LOOP_THREADSAFE + #define USE_SPEAKER #define USE_SPEAKER_MEDIA_PLAYER_ON_OFF #define USE_SPI @@ -295,6 +300,8 @@ #define USE_ETHERNET_OPENETH #define USE_ETHERNET_W5100 #define USE_ETHERNET_W5500 +#define USE_ETHERNET_W6100 +#define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 @@ -329,6 +336,7 @@ // ESP8266-specific feature flags #ifdef USE_ESP8266 #define USE_ADC_SENSOR_VCC +#define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) #define USE_CAPTIVE_PORTAL #define USE_ESP8266_LOGGER_SERIAL @@ -376,7 +384,6 @@ #ifdef USE_LIBRETINY #define USE_CAPTIVE_PORTAL #define USE_SOCKET_IMPL_LWIP_SOCKETS -#define USE_SOCKET_SELECT_SUPPORT #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH @@ -388,7 +395,6 @@ #ifdef USE_HOST #define USE_HTTP_REQUEST_RESPONSE #define USE_SOCKET_IMPL_BSD_SOCKETS -#define USE_SOCKET_SELECT_SUPPORT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 64 #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 0589b92364..f09dd013fe 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -17,7 +17,11 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.config import DEVICE_CLASS_MAX_LENGTH, ICON_MAX_LENGTH +from esphome.core.config import ( + DEVICE_CLASS_MAX_LENGTH, + ICON_MAX_LENGTH, + UNIT_OF_MEASUREMENT_MAX_LENGTH, +) from esphome.cpp_generator import MockObj, RawStatement, add, get_variable import esphome.final_validate as fv from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case @@ -189,9 +193,10 @@ def _register_string( def register_device_class(value: str) -> int: """Register a device_class string and return its 1-based index.""" - if value and len(value) > DEVICE_CLASS_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > DEVICE_CLASS_MAX_LENGTH: raise ValueError( - f"Device class string too long ({len(value)} chars, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" + f"Device class string too long ({byte_len} bytes, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'" ) return _register_string( value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" @@ -200,14 +205,21 @@ def register_device_class(value: str) -> int: def register_unit_of_measurement(value: str) -> int: """Register a unit_of_measurement string and return its 1-based index.""" + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > UNIT_OF_MEASUREMENT_MAX_LENGTH: + raise ValueError( + f"Unit of measurement string too long ({byte_len} bytes, " + f"max {UNIT_OF_MEASUREMENT_MAX_LENGTH}): '{value}'" + ) return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") def register_icon(value: str) -> int: """Register an icon string and return its 1-based index.""" - if value and len(value) > ICON_MAX_LENGTH: + byte_len = len(value.encode("utf-8")) if value else 0 + if byte_len > ICON_MAX_LENGTH: raise ValueError( - f"Icon string too long ({len(value)} chars, max {ICON_MAX_LENGTH}): '{value}'" + f"Icon string too long ({byte_len} bytes, max {ICON_MAX_LENGTH}): '{value}'" ) return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") diff --git a/esphome/core/finite_set_mask.h b/esphome/core/finite_set_mask.h index f9cd0377c7..616c69353d 100644 --- a/esphome/core/finite_set_mask.h +++ b/esphome/core/finite_set_mask.h @@ -119,7 +119,7 @@ template(mask)); + } else if constexpr (sizeof(bitmask_t) <= sizeof(uint32_t)) { + bit = __builtin_ctzl(static_cast(mask)); + } else { + bit = __builtin_ctzll(static_cast(mask)); + } + return bit < BitPolicy::MAX_BITS ? bit : BitPolicy::MAX_BITS; +#else + int bit = 0; while (bit < BitPolicy::MAX_BITS && !(mask & (static_cast(1) << bit))) { ++bit; } return bit; +#endif } protected: diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1732fc72e8..5940f6ec98 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -22,6 +22,19 @@ namespace esphome { static const char *const TAG = "helpers"; +__attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, + size_t elem_size) { + ESPHOME_DEBUG_ASSERT(size < UINT16_MAX); + uint16_t new_cap = size + 1; + auto *new_data = ::operator new(new_cap *elem_size); + if (data) { + __builtin_memcpy(new_data, data, size * elem_size); + ::operator delete(data); + } + capacity = new_cap; + return new_data; +} + static const uint16_t CRC16_A001_LE_LUT_L[] = {0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440}; static const uint16_t CRC16_A001_LE_LUT_H[] = {0x0000, 0xcc01, 0xd801, 0x1400, 0xf001, 0x3c00, 0x2800, 0xe401, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f96b888e28..c26bbe17b7 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1801,33 +1801,77 @@ template struct Callback { } }; +/// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp. +void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, size_t elem_size); + template class CallbackManager; /** Helper class to allow having multiple subscribers to a callback. + * + * Uses a trivial-copyable-specialized container instead of std::vector to avoid + * template bloat (_M_realloc_insert, exception-safe copies). Since Callback is + * trivially copyable (just {fn_ptr, ctx_ptr}), reallocation is a plain memcpy. + * Uses uint16_t for size/capacity (8 bytes on 32-bit vs 12 for std::vector). + * Grows to exact size on each add — callbacks are registered during setup() + * and most instances have only 1-2 callbacks, so slack capacity is wasteful. * * @tparam Ts The arguments for the callbacks, wrapped in void(). */ template class CallbackManager { + using CbType = Callback; + static_assert(std::is_trivially_copyable_v, "Callback must be trivially copyable"); + public: + CallbackManager() = default; + ~CallbackManager() { ::operator delete(this->data_); } + + // Non-copyable (would alias data_), movable (for std::map support) + CallbackManager(const CallbackManager &) = delete; + CallbackManager &operator=(const CallbackManager &) = delete; + CallbackManager(CallbackManager &&other) noexcept + : data_(other.data_), size_(other.size_), capacity_(other.capacity_) { + other.data_ = nullptr; + other.size_ = 0; + other.capacity_ = 0; + } + CallbackManager &operator=(CallbackManager &&other) noexcept { + std::swap(this->data_, other.data_); + std::swap(this->size_, other.size_); + std::swap(this->capacity_, other.capacity_); + return *this; + } + /// Add any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline without heap allocation or std::function. - template void add(F &&callback) { this->add_(Callback::create(std::forward(callback))); } - - /// Call all callbacks in this manager. No null check on invoke. - void call(Ts... args) { - for (auto &cb : this->callbacks_) - cb.call(args...); - } - size_t size() const { return this->callbacks_.size(); } + template void add(F &&callback) { this->add_(CbType::create(std::forward(callback))); } /// Call all callbacks in this manager. - void operator()(Ts... args) { call(args...); } + inline void ESPHOME_ALWAYS_INLINE call(Ts... args) { + if (this->size_ != 0) { + for (auto *it = this->data_, *end = it + this->size_; it != end; ++it) { + it->call(args...); + } + } + } + uint16_t size() const { return this->size_; } + + /// Call all callbacks in this manager. + void operator()(Ts... args) { this->call(args...); } protected: template friend class LazyCallbackManager; /// Non-template core to avoid code duplication per lambda type. - void add_(Callback cb) { this->callbacks_.push_back(cb); } - std::vector> callbacks_; + /// Inline fast path; cold growth path is in helpers.cpp via callback_manager_grow(). + void add_(CbType cb) { + if (this->size_ == this->capacity_) { + this->data_ = + static_cast(callback_manager_grow(this->data_, this->size_, this->capacity_, sizeof(CbType))); + } + this->data_[this->size_++] = cb; + } + CbType *data_{nullptr}; + uint16_t size_{0}; + uint16_t capacity_{0}; }; /** CallbackManager backed by StaticVector for compile-time-known callback counts. @@ -1871,7 +1915,7 @@ template class LazyCallbackManager; * from API and web_server components). * * Memory overhead comparison (32-bit systems): - * - CallbackManager: 12 bytes (empty std::vector) + * - CallbackManager: 8 bytes (pointer + uint16 size + uint16 capacity) * - LazyCallbackManager: 4 bytes (nullptr pointer) * * Uses plain pointer instead of unique_ptr to avoid template instantiation overhead. diff --git a/esphome/core/log.h b/esphome/core/log.h index ff39633142..72e06cabac 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -54,8 +54,8 @@ namespace esphome { #define ESPHOME_LOG_COLOR_CYAN "36" // DEBUG #define ESPHOME_LOG_COLOR_GRAY "37" // VERBOSE #define ESPHOME_LOG_COLOR_WHITE "38" -#define ESPHOME_LOG_SECRET_BEGIN "\033[5m" -#define ESPHOME_LOG_SECRET_END "\033[6m" +#define ESPHOME_LOG_SECRET_BEGIN "\033[8m" +#define ESPHOME_LOG_SECRET_END "\033[28m" #define LOG_SECRET(x) ESPHOME_LOG_SECRET_BEGIN x ESPHOME_LOG_SECRET_END #define ESPHOME_LOG_COLOR(COLOR) "\033[0;" COLOR "m" diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index a695fa396b..bb3acbafcb 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -63,12 +63,12 @@ // // Shared state and safety rationale: // -// s_main_loop_task (TaskHandle_t, 4 bytes): -// Written once by main loop in init(). Read by TCP/IP thread (in callback) -// and background tasks (in wake). -// Safe: write-once-then-read pattern. Socket hooks may run before init(), -// but the NULL check on s_main_loop_task in the callback provides correct -// degraded behavior — notifications are simply skipped until init() completes. +// esphome_main_task_handle (TaskHandle_t, 4 bytes, defined in main_task.c): +// Written once by main loop in Application::setup(). Read by TCP/IP thread +// (in callback) and background tasks (in wake). +// Safe: write-once-then-read pattern. Socket hooks may run before setup(), +// but the NULL check on esphome_main_task_handle in the callback provides correct +// degraded behavior — notifications are simply skipped until setup() completes. // // s_original_callback (netconn_callback, 4-byte function pointer): // Written by main loop in hook_socket() (only when NULL — set once). @@ -123,15 +123,10 @@ #endif #include "esphome/core/lwip_fast_select.h" +#include "esphome/core/main_task.h" #include -// IRAM_ATTR is defined by esp_attr.h (included via FreeRTOS headers) on ESP32. -// On LibreTiny it's not defined — provide a no-op fallback. -#ifndef IRAM_ATTR -#define IRAM_ATTR -#endif - // Compile-time verification of thread safety assumptions. // On ESP32 (Xtensa/RISC-V) and LibreTiny (ARM Cortex-M), naturally-aligned // reads/writes up to 32 bits are atomic. @@ -157,8 +152,7 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET, "lwip_sock.rcvevent offset changed — update ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET in lwip_fast_select.h"); -// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks. -static TaskHandle_t s_main_loop_task = NULL; +// Task handle is in main_task.c (esphome_main_task_handle) — shared with wake.h. // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; @@ -177,15 +171,13 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { - TaskHandle_t task = s_main_loop_task; + TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); } } } -void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } - // lwip_socket_dbg_get_socket() is a thin wrapper around the static // tryget_socket_unconn_nouse() — a direct array lookup without the refcount // that get_socket()/done_socket() uses. This is safe because: @@ -232,35 +224,4 @@ bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable) { return true; } -// Wake the main loop from another FreeRTOS task. NOT ISR-safe. -void esphome_lwip_wake_main_loop(void) { - TaskHandle_t task = s_main_loop_task; - if (task != NULL) { - xTaskNotifyGive(task); - } -} - -// Wake the main loop from an ISR. ISR-safe variant. -void IRAM_ATTR esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken) { - TaskHandle_t task = s_main_loop_task; - if (task != NULL) { - vTaskNotifyGiveFromISR(task, (BaseType_t *) px_higher_priority_task_woken); - } -} - -// Wake the main loop from any context (ISR, thread, or main loop). -// ESP32-only: uses xPortInIsrContext() to detect ISR context. -// LibreTiny is excluded because it lacks IRAM_ATTR support needed for ISR-safe paths. -#ifdef USE_ESP32 -void IRAM_ATTR esphome_lwip_wake_main_loop_any_context(void) { - if (xPortInIsrContext()) { - int px_higher_priority_task_woken = 0; - esphome_lwip_wake_main_loop_from_isr(&px_higher_priority_task_woken); - portYIELD_FROM_ISR(px_higher_priority_task_woken); - } else { - esphome_lwip_wake_main_loop(); - } -} -#endif - #endif // USE_LWIP_FAST_SELECT diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 50706ba9f6..20ac191673 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -20,10 +20,6 @@ enum { ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET = 8 }; extern "C" { #endif -/// Initialize fast select — must be called from the main loop task during setup(). -/// Saves the current task handle for xTaskNotifyGive() wake notifications. -void esphome_lwip_fast_select_init(void); - /// Look up a LwIP socket struct from a file descriptor. /// Returns NULL if fd is invalid or the socket/netconn is not initialized. /// Use this at registration time to cache the pointer for esphome_lwip_socket_has_data(). @@ -57,15 +53,6 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); -/// Wake the main loop task from another FreeRTOS task — costs <1 us. -/// NOT ISR-safe — must only be called from task context. -void esphome_lwip_wake_main_loop(void); - -/// Wake the main loop task from an ISR — costs <1 us. -/// ISR-safe variant using vTaskNotifyGiveFromISR(). -/// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. -void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken); - /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, @@ -73,13 +60,6 @@ void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken); /// Returns true if successful, false if sock/conn/pcb is NULL or the socket is not TCP. bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable); -/// Wake the main loop task from any context (ISR, thread, or main loop). -/// ESP32-only: uses xPortInIsrContext() to detect ISR context. -/// LibreTiny lacks IRAM_ATTR support needed for ISR-safe paths. -#ifdef USE_ESP32 -void esphome_lwip_wake_main_loop_any_context(void); -#endif - #ifdef __cplusplus } #endif diff --git a/esphome/core/main_task.c b/esphome/core/main_task.c new file mode 100644 index 0000000000..52d9c2951a --- /dev/null +++ b/esphome/core/main_task.c @@ -0,0 +1,5 @@ +#include "esphome/core/main_task.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +TaskHandle_t esphome_main_task_handle = NULL; +#endif diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h new file mode 100644 index 0000000000..ed2885d2e2 --- /dev/null +++ b/esphome/core/main_task.h @@ -0,0 +1,55 @@ +#pragma once + +/// Main loop task handle and wake helpers — shared between wake.h (C++) and lwip_fast_select.c (C). +/// esphome_main_task_handle is set once during Application::setup() via xTaskGetCurrentTaskHandle(). + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#ifdef USE_ESP32 +#include +#include +#else +#include +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +extern TaskHandle_t esphome_main_task_handle; + +/// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. +static inline void esphome_main_task_notify() { + TaskHandle_t task = esphome_main_task_handle; + if (task != NULL) { + xTaskNotifyGive(task); + } +} + +/// Wake the main loop task from an ISR. ISR-safe. +static inline void esphome_main_task_notify_from_isr(BaseType_t *px_higher_priority_task_woken) { + TaskHandle_t task = esphome_main_task_handle; + if (task != NULL) { + vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); + } +} + +#ifdef USE_ESP32 +/// Wake the main loop from any context (ISR or task). ESP32-only (needs xPortInIsrContext). +static inline void esphome_main_task_notify_any_context() { + if (xPortInIsrContext()) { + int px_higher_priority_task_woken = 0; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); + portYIELD_FROM_ISR(px_higher_priority_task_woken); + } else { + esphome_main_task_notify(); + } +} +#endif + +#ifdef __cplusplus +} +#endif + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 9ee3b2fdd2..dff50b03ef 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -214,8 +214,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type #endif /* ESPHOME_DEBUG_SCHEDULER */ } - // Common epilogue: atomic cancel-and-add (unless skip_cancel is true) - if (!skip_cancel) { + // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) + // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. + if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, /* find_first= */ true); } @@ -528,7 +529,7 @@ void HOT Scheduler::call(uint32_t now) { const auto now_64 = this->millis_64_from_(now); this->process_to_add(); - // Track if any items were added to to_add_ during this call (intervals or from callbacks) + // Track if any items were added to to_add_ during callbacks bool has_added_items = false; #ifdef ESPHOME_DEBUG_SCHEDULER @@ -577,6 +578,12 @@ void HOT Scheduler::call(uint32_t now) { if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { this->full_cleanup_removed_items_(); } + // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. + // This is intentional — fired intervals are pushed back into items_ via + // push_back() + push_heap() below, which may reallocate the vector's storage. + // Index-based access is safe across reallocations because we re-read items_[0] + // at the top of each iteration. Do NOT convert this to a range-based for loop + // or iterator-based loop, as that would break when items are added. while (!this->items_.empty()) { // Don't copy-by value yet SchedulerItem *item = this->items_[0]; @@ -645,10 +652,18 @@ void HOT Scheduler::call(uint32_t now) { if (executed_item->type == SchedulerItem::INTERVAL) { executed_item->set_next_execution(now_64 + executed_item->interval); - // Add new item directly to to_add_ - // since we have the lock held - this->to_add_.push_back(executed_item); - this->to_add_count_increment_(); + // Push directly back into the heap instead of routing through to_add_. + // This is safe because: + // 1. We're on the main loop and already hold the lock + // 2. The item was already popped from items_ via pop_raw_locked_() above + // 3. The while loop uses index-based access (items_[0]), not iterators, + // so push_back() reallocation cannot invalidate our iteration + // 4. push_heap() restores the heap invariant before the next iteration + // peeks at items_[0] + // This avoids the to_add_ detour and the overhead of + // process_to_add_slow_path_() (lock acquisition, vector iteration, clear). + this->items_.push_back(executed_item); + std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } else { // Timeout completed - recycle it this->recycle_item_main_loop_(executed_item); @@ -742,6 +757,23 @@ bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const // When find_first=false, cancels ALL matches across all containers (needed for // public cancel path where DelayAction parallel mode can create duplicates). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id +size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vector &container, + Component *component, NameType name_type, + const char *static_name, uint32_t hash_or_id, + SchedulerItem::Type type, bool match_retry, + bool find_first) { + size_t count = 0; + for (auto *item : container) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + this->set_item_removed_(item, true); + if (find_first) + return 1; + count++; + } + } + return count; +} + bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first) { @@ -767,7 +799,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // The main loop may be executing an item's callback right now, and recycling // would destroy the callback while it's running (use-after-free). // Only the main loop in call() should recycle items after execution completes. - if (!this->items_.empty()) { + { size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 1e44f41da8..43a3ec7049 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -495,23 +495,23 @@ class Scheduler { // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // Returns the number of items marked for removal. // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, - Component *component, NameType name_type, - const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry, - bool find_first = false) { - size_t count = 0; - for (auto *item : container) { - if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { - this->set_item_removed_(item, true); - if (find_first) - return 1; - count++; - } - } - return count; + // Inlined: the fast path (empty container) avoids calling the out-of-line scan. + inline size_t HOT mark_matching_items_removed_locked_(std::vector &container, Component *component, + NameType name_type, const char *static_name, + uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, + bool find_first = false) { + if (container.empty()) + return 0; + return this->mark_matching_items_removed_slow_locked_(container, component, name_type, static_name, hash_or_id, + type, match_retry, find_first); } + // Out-of-line slow path for mark_matching_items_removed_locked_ when container is non-empty. + // IMPORTANT: Must be called with scheduler lock held + __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_( + std::vector &container, Component *component, NameType name_type, const char *static_name, + uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first); + Mutex lock_; std::vector items_; std::vector to_add_; diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp new file mode 100644 index 0000000000..b6b59b5990 --- /dev/null +++ b/esphome/core/wake.cpp @@ -0,0 +1,82 @@ +#include "esphome/core/wake.h" +#include "esphome/core/hal.h" + +#ifdef USE_ESP8266 +#include +#endif + +#ifdef USE_HOST +#include "esphome/core/application.h" +#include +#endif + +namespace esphome { + +// === ESP32 — IRAM_ATTR entry points === +#ifdef USE_ESP32 +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} +void IRAM_ATTR wake_loop_any_context() { esphome_main_task_notify_any_context(); } +#endif + +// === ESP8266 / RP2040 === +#if defined(USE_ESP8266) || defined(USE_RP2040) +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile bool g_main_loop_woke = false; +#endif + +#ifdef USE_ESP8266 +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } +#endif + +// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === +#ifdef USE_RP2040 +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal +#endif // USE_RP2040 + +// === Host (UDP loopback socket) === +#ifdef USE_HOST +void wake_loop_threadsafe() { + if (App.wake_socket_fd_ >= 0) { + const char dummy = 1; + ::send(App.wake_socket_fd_, &dummy, 1, 0); + } +} +#endif + +} // namespace esphome diff --git a/esphome/core/wake.h b/esphome/core/wake.h new file mode 100644 index 0000000000..a8c9b7ad08 --- /dev/null +++ b/esphome/core/wake.h @@ -0,0 +1,126 @@ +#pragma once + +/// @file wake.h +/// Platform-specific main loop wake primitives. +/// Always available on all platforms — no opt-in needed. + +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/main_task.h" +#endif +#ifdef USE_ESP8266 +#include +#elif defined(USE_RP2040) +#include +#include +#endif + +namespace esphome { + +// === Wake flag for ESP8266/RP2040 === +#if defined(USE_ESP8266) || defined(USE_RP2040) +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern volatile bool g_main_loop_woke; +#endif + +// === ESP32 / LibreTiny (FreeRTOS) === +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#ifdef USE_ESP32 +/// IRAM_ATTR entry point — defined in wake.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +/// IRAM_ATTR entry point — defined in wake.cpp. +void wake_loop_any_context(); +#else +/// LibreTiny: IRAM_ATTR is not functional and the FreeRTOS port does not +/// provide vTaskNotifyGiveFromISR/portYIELD_FROM_ISR, so ISR-safe wake +/// is not possible. xTaskNotifyGive is used as the best available option. +inline void wake_loop_any_context() { esphome_main_task_notify(); } +#endif + +inline void wake_loop_threadsafe() { esphome_main_task_notify(); } + +namespace internal { +inline void wakeable_delay(uint32_t ms) { + if (ms == 0) { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +// === ESP8266 === +#elif defined(USE_ESP8266) + +/// Inline implementation — IRAM callers inline this directly. +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +namespace internal { +inline void wakeable_delay(uint32_t ms) { + if (ms == 0) { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +// === RP2040 === +#elif defined(USE_RP2040) + +inline void wake_loop_any_context() { + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +// === Host / Zephyr / other === +#else + +#ifdef USE_HOST +/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. +void wake_loop_threadsafe(); +#else +/// Zephyr is currently the only platform without a wake mechanism. +/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). +/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. +inline void wake_loop_threadsafe() {} +#endif + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +inline void wakeable_delay(uint32_t ms) { + if (ms == 0) { + yield(); + return; + } + delay(ms); +} +} // namespace internal + +#endif + +} // namespace esphome diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a8efe96cce..cf90b878e1 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -819,11 +819,17 @@ async def templatable( args: list[tuple[SafeExpType, str]], output_type: SafeExpType | None, to_exp: Callable | dict = None, + *, + wrap_constant: bool = False, ): """Generate code for a templatable config option. If `value` is a templated value, the lambda expression is returned. - Otherwise the value is returned as-is (optionally process with to_exp). + For std::string output, constants are returned as-is (with PROGMEM wrapping), + using the std::string-specific TemplatableValue specialization. + For all other output types, constants are wrapped in stateless lambdas + so that TemplatableFn-backed macro-generated fields can store them as + function pointers. :param value: The value to process. :param args: The arguments for the lambda expression. @@ -833,20 +839,28 @@ async def templatable( """ if is_template(value): return await process_lambda(value, args, return_type=output_type) - if to_exp is None: + # Late import to avoid circular dependency (cpp_generator <-> cpp_types). + from esphome.cpp_types import std_string + + if to_exp is not None: + value = to_exp[value] if isinstance(to_exp, dict) else to_exp(value) + elif ( + isinstance(value, str) and output_type is not None and output_type is std_string + ): # Automatically wrap static strings in ESPHOME_F() for PROGMEM storage on ESP8266. # On other platforms ESPHOME_F() is a no-op returning const char*. - # Lazy import to avoid circular dependency (cpp_generator <-> cpp_types). - # Identity check (is) avoids brittle string comparison. - if isinstance(value, str) and output_type is not None: - from esphome.cpp_types import std_string - - if output_type is std_string: - return FlashStringLiteral(value) - return value - if isinstance(to_exp, dict): - return to_exp[value] - return to_exp(value) + return FlashStringLiteral(value) + # Wrap non-string constants in stateless lambdas so that TemplatableFn + # (used by TEMPLATABLE_VALUE macro) stores them as function pointers. + # wrap_constant=True forces wrapping even with output_type=None (compiler deduces type). + if (output_type is not None or wrap_constant) and output_type is not std_string: + return LambdaExpression( + f"return {safe_exp(value)};", + args, + capture="", + return_type=output_type, + ) + return value class MockObj(Expression): diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 8dd77de843..aeaa4480a8 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -13,6 +13,7 @@ std_string = std_ns.class_("string") std_string_ref = std_ns.namespace("string &") std_vector = std_ns.class_("vector") std_span = std_ns.class_("span") +int8 = global_ns.namespace("int8_t") uint8 = global_ns.namespace("uint8_t") uint16 = global_ns.namespace("uint16_t") uint32 = global_ns.namespace("uint32_t") diff --git a/esphome/espota2.py b/esphome/espota2.py index c412bb51ff..39f51e02e9 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -40,6 +40,8 @@ RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE = 0x88 RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE = 0x89 RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A RESPONSE_ERROR_MD5_MISMATCH = 0x8B +RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C +RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -128,7 +130,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None :param expect: Expected response code(s), None to skip validation. :raises OTAError: If an error code is detected or response doesn't match expected. """ - if not expect: + if expect is None: return if not data: raise OTAError( @@ -192,6 +194,12 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None "Error: Application MD5 code mismatch. Please try again " "or flash over USB with a good quality cable." ) + if dat == RESPONSE_ERROR_SIGNATURE_INVALID: + raise OTAError( + "Error: Firmware signature verification failed. The firmware was not signed " + "with the correct key. Ensure the signing key matches the one used to build " + "the firmware currently running on the device." + ) if dat == RESPONSE_ERROR_UNKNOWN: raise OTAError("Unknown error from ESP") if not isinstance(expect, (list, tuple)): @@ -270,7 +278,7 @@ def perform_ota( raise OTAError("ESP requests password, but no password given!") nonce_bytes = receive_exactly( - sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False + sock, nonce_size, f"{hash_name} authentication nonce", None, decode=False ) assert isinstance(nonce_bytes, bytes) nonce = nonce_bytes.decode() diff --git a/esphome/expression.py b/esphome/expression.py new file mode 100644 index 0000000000..d425d822a4 --- /dev/null +++ b/esphome/expression.py @@ -0,0 +1,25 @@ +"""Helpers for detecting substitution variables and Jinja expressions.""" + +import re + +from esphome.const import VALID_SUBSTITUTIONS_CHARACTERS + +SUBSTITUTION_VARIABLE_PROG = re.compile( + rf"\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\}})" +) + +_JINJA_RE = re.compile( + r"<%.+?%>" # Block: <% ... %> + r"|\$\{[^}]+\}", # Braced: ${ ... } + flags=re.MULTILINE, +) + + +def has_jinja(value: str) -> bool: + """Check if a string contains Jinja expressions.""" + return _JINJA_RE.search(value) is not None + + +def has_substitution_or_expression(value: str) -> bool: + """Check if a string contains substitution variables ($name, ${name}) or Jinja expressions.""" + return SUBSTITUTION_VARIABLE_PROG.search(value) is not None or has_jinja(value) diff --git a/esphome/external_files.py b/esphome/external_files.py index 72a3f33fdc..18b68fba08 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime import logging from pathlib import Path @@ -27,8 +27,8 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: local_modification_time = local_file_path.stat().st_mtime - local_modification_time_str = datetime.utcfromtimestamp( - local_modification_time + local_modification_time_str = datetime.fromtimestamp( + local_modification_time, tz=UTC ).strftime("%a, %d %b %Y %H:%M:%S GMT") headers = { diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e001316a22..59d851c02e 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 @@ -33,6 +33,7 @@ from esphome.core import ( MACAddress, TimePeriod, ) +from esphome.expression import has_substitution_or_expression from esphome.helpers import add_class_to_obj from esphome.util import OrderedDict, filter_yaml_files @@ -44,6 +45,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: @@ -89,24 +111,6 @@ def make_data_base( return value -class ConfigContext: - """This is a mixin class that holds substitution vars that should be applied - to the tagged node and its children. During configuration loading, context vars can - be added to nodes using `add_context` function, which applies the mixin storing - the captured values and unevaluated expressions. - The substitution pass then recreates the effective context by merging the context vars - from this node and parent nodes. - """ - - @property - def vars(self) -> dict[str, Any]: - return self._context_vars - - def set_context(self, vars: dict[str, Any]) -> None: - # pylint: disable=attribute-defined-outside-init - self._context_vars = vars - - def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -130,6 +134,94 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: return value +class ConfigContext: + """This is a mixin class that holds substitution vars that should be applied + to the tagged node and its children. During configuration loading, context vars can + be added to nodes using `add_context` function, which applies the mixin storing + the captured values and unevaluated expressions. + The substitution pass then recreates the effective context by merging the context vars + from this node and parent nodes. + """ + + @property + def vars(self) -> dict[str, Any]: + return self._context_vars + + def set_context(self, vars: dict[str, Any]) -> None: + # pylint: disable=attribute-defined-outside-init + self._context_vars = vars + + def copy_context_to_children(self) -> None: + """Propagate context to children. + + isinstance(self, dict/list) works because ConfigContext is dynamically + mixed into dict/list subclasses via add_class_to_obj in add_context(). + """ + if isinstance(self, dict): + # pylint: disable=no-member + tagged = { + add_context(k, self.vars): add_context(v, self.vars) + for k, v in self.items() + } + self.clear() + self.update(tagged) + elif isinstance(self, list): + for i, item in enumerate(self): + # pylint: disable=unsupported-assignment-operation + self[i] = add_context(item, self.vars) + + +_UNSET = object() + + +class IncludeFile: + """Deferred !include that is resolved during the substitution pass. + + Created during YAML parsing instead of loading the file immediately, + allowing substitution variables to appear in the filename path + (e.g. ``!include device-${platform}.yaml``). The actual file is + loaded on the first call to ``load()``, and the result is cached. + """ + + def __init__( + self, + parent_file: Path, + file: Path | str, + vars: dict[str, Any] | None, + yaml_loader: Callable[[Path], Any], + ) -> None: + self.parent_file = parent_file + self.file = Path(file) + self.vars = vars + self.yaml_loader = yaml_loader + self._content: Any = _UNSET + + def __repr__(self) -> str: + return f"IncludeFile({self.file.as_posix()})" + + def load(self) -> Any: + """Load and cache the included file content. + + Note: returns the cached mutable object on subsequent calls. + Callers that need to modify the result should copy it first. + """ + if self._content is not _UNSET: + return self._content + if self.has_unresolved_expressions(): + from esphome.config_validation import Invalid + + raise Invalid( + f"Cannot load include with unresolved substitutions: {self.file}" + ) + self._content = self.yaml_loader(Path(self.parent_file.parent / self.file)) + self._content = add_context(self._content, self.vars) + return self._content + + def has_unresolved_expressions(self) -> bool: + """Check if the filename contains substitution variables or Jinja expressions.""" + return has_substitution_or_expression(str(self.file)) + + def _add_data_ref(fn): @functools.wraps(fn) def wrapped(loader, node): @@ -149,6 +241,36 @@ def _add_data_ref(fn): return wrapped +_MAX_MERGE_INCLUDE_DEPTH = 10 + + +def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) -> Any: + """Resolve an IncludeFile (and chains) and propagate context for merge key handling.""" + for _ in range(_MAX_MERGE_INCLUDE_DEPTH): + if not isinstance(value, IncludeFile): + break + if value.has_unresolved_expressions(): + raise yaml.constructor.ConstructorError( + "While constructing a mapping", + node.start_mark, + "Substitution in include filename with merge keys is not supported yet.", + value_node.start_mark, + ) + value = value.load() + else: + raise yaml.constructor.ConstructorError( + "While constructing a mapping", + node.start_mark, + f"Maximum include chain depth ({_MAX_MERGE_INCLUDE_DEPTH}) exceeded in merge key", + value_node.start_mark, + ) + if isinstance(value, ConfigContext): + # Since the parent dict/list will disappear, propagate + # context to children now to retain context vars + value.copy_context_to_children() + return value + + class ESPHomeLoaderMixin: """Loader class that keeps track of line numbers.""" @@ -240,6 +362,9 @@ class ESPHomeLoaderMixin: # This is a merge key, resolve value and add to merge_pairs value = self.construct_object(value_node) + + value = _resolve_merge_include(value, node, value_node) + if isinstance(value, dict): # base case, copy directly to merge_pairs # direct merge, like "<<: {some_key: some_value}" @@ -247,6 +372,7 @@ class ESPHomeLoaderMixin: elif isinstance(value, list): # sequence merge, like "<<: [{some_key: some_value}, {other_key: some_value}]" for item in value: + item = _resolve_merge_include(item, node, value_node) if not isinstance(item, dict): raise yaml.constructor.ConstructorError( "While constructing a mapping", @@ -341,8 +467,11 @@ class ESPHomeLoaderMixin: else: file, vars = node.value, None - result = self.yaml_loader(self._rel_path(file)) - return add_context(result, vars) + return IncludeFile(self.name, file, vars, self.yaml_loader) + + # Directory includes (!include_dir_*) load eagerly during YAML parsing + # because their paths are directory names, not individual files, and + # substitutions in directory paths are not supported. @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: @@ -466,17 +595,24 @@ 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) + res = parse_yaml(fname, f_handle) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err + # Top-level !include returns a deferred IncludeFile; resolve it so + # callers always receive the final content. + if isinstance(res, IncludeFile): + res = res.load() + return res -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 @@ -623,6 +759,14 @@ class ESPHomeDumper(yaml.SafeDumper): def represent_remove(self, value): return self.represent_scalar(tag="!remove", value=value.value) + def represent_include_file(self, value): + if value.vars: + mapping = {"file": value.file.as_posix(), "vars": value.vars} + return self.represent_mapping( + tag="!include", mapping=mapping, flow_style=False + ) + return self.represent_scalar(tag="!include", value=value.file.as_posix()) + def represent_id(self, value): if is_secret(value.id): return self.represent_secret(value.id) @@ -654,3 +798,4 @@ ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index dc4ca77eb4..dd45b58a6c 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -25,7 +25,7 @@ _BACKGROUND_TASKS: set[asyncio.Task] = set() class DashboardStatus: - def __init__(self, on_update: Callable[[dict[str, bool | None], []]]) -> None: + def __init__(self, on_update: Callable[[dict[str, bool | None]], None]) -> None: """Initialize the dashboard status.""" self.on_update = on_update diff --git a/requirements.txt b/requirements.txt index dd20600097..d7db44454c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.6 +cryptography==46.0.7 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 @@ -10,9 +10,9 @@ tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 -click==8.3.1 -esphome-dashboard==20260210.0 -aioesphomeapi==44.8.1 +click==8.3.2 +esphome-dashboard==20260408.1 +aioesphomeapi==44.13.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import 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/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f2a11141af..526644842d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -56,6 +56,10 @@ FILE_HEADER = """// This file was automatically generated with a tool. // See script/api_protobuf/api_protobuf.py """ +# Populated by main() before any TypeInfo creation. +# Maps enum type name (e.g. ".BluetoothDeviceRequestType") to max enum value. +_enum_max_values: dict[str, int] = {} + def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" @@ -156,6 +160,16 @@ class TypeInfo(ABC): """Check if this field should always be encoded (skip zero/empty check).""" return get_field_opt(self._field, pb.force, False) + @property + def max_value(self) -> int | None: + """Get the max_value option for this field, or None if not set.""" + return get_field_opt(self._field, pb.max_value, None) + + @property + def max_data_length(self) -> int | None: + """Get the max_data_length option for this field, or None if not set.""" + return get_field_opt(self._field, pb.max_data_length, None) + @property def wire_type(self) -> WireType: """Get the wire type for the field.""" @@ -227,46 +241,63 @@ class TypeInfo(ABC): # eliminating the zero-check branch and encode_field_raw indirection. # {value} is replaced with the actual field expression. RAW_ENCODE_MAP: dict[str, str] = { - "encode_uint32": "buffer.encode_varint_raw({value});", - "encode_uint64": "buffer.encode_varint_raw_64({value});", - "encode_sint32": "buffer.encode_varint_raw(encode_zigzag32({value}));", - "encode_sint64": "buffer.encode_varint_raw_64(encode_zigzag64({value}));", - "encode_int64": "buffer.encode_varint_raw_64(static_cast({value}));", - "encode_bool": "buffer.write_raw_byte({value} ? 0x01 : 0x00);", + "encode_uint32": "ProtoEncode::encode_varint_raw(pos, {value});", + "encode_uint64": "ProtoEncode::encode_varint_raw_64(pos, {value});", + "encode_sint32": "ProtoEncode::encode_varint_raw_short(pos, encode_zigzag32({value}));", + "encode_sint64": "ProtoEncode::encode_varint_raw_64(pos, encode_zigzag64({value}));", + "encode_int64": "ProtoEncode::encode_varint_raw_64(pos, static_cast({value}));", + "encode_bool": "ProtoEncode::write_raw_byte(pos, {value} ? 0x01 : 0x00);", } def _encode_with_precomputed_tag(self, value_expr: str) -> str | None: - """Try to emit a precomputed-tag encode for a forced field. + """Try to emit a precomputed-tag encode for a field. + + For forced fields: emits raw tag + value unconditionally. + For non-forced fields with single-byte tag: emits inline zero-check + + raw tag + value, avoiding an outlined function call. Returns the raw encode string if the tag is a single byte and the encode_func has a known raw equivalent, or None otherwise. """ - if not self.force: - return None tag = self.calculate_tag() if tag >= 128: return None - raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func) + max_val = self.max_value + # Only use RAW_ENCODE_MAP for forced fields or fields with max_value + raw_expr = None + if self.force or max_val is not None: + raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func) if raw_expr is None: return None - return f"buffer.write_raw_byte({tag});\n{raw_expr.format(value=value_expr)}" + body = f"ProtoEncode::write_raw_byte(pos, {tag});\n{raw_expr.format(value=value_expr)}" + if self.force: + return body + # Non-forced with max_value: inline zero-check + raw encode + return f"if ({value_expr}) {{\n {body}\n}}" def _encode_bytes_with_precomputed_tag( - self, data_expr: str, len_expr: str + self, data_expr: str, len_expr: str, max_len: int | None = None ) -> str | None: """Try to emit a precomputed-tag encode for a forced bytes/string field. Returns the raw encode string if the tag is a single byte, or None. + When max_len < 128, uses direct byte write for the length varint. """ if not self.force: return None tag = self.calculate_tag() if tag >= 128: return None + # When max_len < 128, length varint is always 1 byte + len_encode = ( + f"ProtoEncode::write_raw_byte(pos, static_cast({len_expr}));" + if max_len is not None and max_len < 128 + else f"ProtoEncode::encode_varint_raw(pos, {len_expr});" + ) return ( - f"buffer.write_raw_byte({tag});\n" - f"buffer.encode_varint_raw({len_expr});\n" - f"buffer.encode_raw({data_expr}, {len_expr});" + f"ProtoEncode::write_raw_byte(pos, {tag});\n" + f"{len_encode}\n" + f"ProtoEncode::encode_raw(pos, {data_expr}, {len_expr});" ) @property @@ -274,8 +305,8 @@ class TypeInfo(ABC): if result := self._encode_with_precomputed_tag(f"this->{self.field_name}"): return result if self.force: - return f"buffer.{self.encode_func}({self.number}, this->{self.field_name}, true);" - return f"buffer.{self.encode_func}({self.number}, this->{self.field_name});" + return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);" + return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});" encode_func = None @@ -346,6 +377,31 @@ class TypeInfo(ABC): value = value_expr or name return f"size += ProtoSize::{method}({field_id_size}, {value});" + def _get_single_byte_varint_size( + self, + name: str, + force: bool, + extra_expr: str | None = None, + zero_check: str | None = None, + ) -> str: + """Size calculation when the varint is guaranteed to be 1 byte. + + Used when max_value < 128 or fixed_array_size < 128. + The fixed part is field_id_size + 1 (tag + 1-byte varint). + + Args: + name: Expression to check for zero (non-force only) + force: Whether to skip the zero check + extra_expr: Additional variable expression to add (e.g., data length) + zero_check: Override expression for the zero check (e.g., "!x.empty()") + """ + fixed = self.calculate_field_id_size() + 1 + size_expr = f"{fixed} + {extra_expr}" if extra_expr else str(fixed) + if force: + return f"size += {size_expr};" + check = zero_check or name + return f"size += {check} ? {size_expr} : 0;" + @abstractmethod def get_size_calculation(self, name: str, force: bool = False) -> str: """Calculate the size needed for encoding this field. @@ -614,10 +670,10 @@ class Fixed32Type(TypeInfo): tag = self.calculate_tag() if self.force and tag < 128: # Emit combined tag+value write: precomputed tag + direct memcpy - return f"buffer.write_tag_and_fixed32({tag}, this->{self.field_name});" + return f"ProtoEncode::write_tag_and_fixed32(pos, {tag}, this->{self.field_name});" if self.force: - return f"buffer.{self.encode_func}({self.number}, this->{self.field_name}, true);" - return f"buffer.{self.encode_func}({self.number}, this->{self.field_name});" + return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);" + return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});" def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() @@ -691,8 +747,8 @@ class StringType(TypeInfo): ): return result if self.force: - return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_, true);" - return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" + return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_, true);" + return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_);" def dump(self, name): # If name is 'it', this is a repeated field element - always use string @@ -779,8 +835,8 @@ class MessageType(TypeInfo): @property def encode_content(self) -> str: - # encode_sub_message always encodes (uses backpatch), no force needed - return f"buffer.{self.encode_func}({self.number}, this->{self.field_name});" + # Sub-message encoding needs buffer for backpatch/sync + return f"ProtoEncode::{self.encode_func}(pos, buffer, {self.number}, this->{self.field_name});" @property def decode_length(self) -> str: @@ -861,8 +917,8 @@ class BytesType(TypeInfo): ): return result if self.force: - return f"buffer.encode_bytes({self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_, true);" - return f"buffer.encode_bytes({self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_);" + return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_, true);" + return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_);" def dump(self, name: str) -> str: ptr_dump = f"format_hex_pretty(this->{self.field_name}_ptr_, this->{self.field_name}_len_)" @@ -972,8 +1028,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ): return result if self.force: - return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" - return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" + return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" + return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);" @property def decode_length_content(self) -> str | None: @@ -1020,15 +1076,21 @@ class PointerToStringBufferType(PointerToBufferTypeBase): @property def encode_content(self) -> str: + max_len = self.max_data_length + if max_len is not None and max_len < 128 and self.force: + tag = self.calculate_tag() + if tag < 128: + return f"ProtoEncode::encode_short_string_force(pos, {tag}, this->{self.field_name});" if result := self._encode_bytes_with_precomputed_tag( - f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()" + f"this->{self.field_name}.c_str()", + f"this->{self.field_name}.size()", ): return result if self.force: - return ( - f"buffer.encode_string({self.number}, this->{self.field_name}, true);" - ) - return f"buffer.encode_string({self.number}, this->{self.field_name});" + return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}, true);" + return ( + f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name});" + ) @property def decode_length_content(self) -> str | None: @@ -1046,7 +1108,16 @@ class PointerToStringBufferType(PointerToBufferTypeBase): return f'dump_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});' def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}.size());" + size_field = f"this->{self.field_name}.size()" + max_len = self.max_data_length + if max_len is not None and max_len < 128: + return self._get_single_byte_varint_size( + size_field, + force, + extra_expr=size_field, + zero_check=f"!this->{self.field_name}.empty()", + ) + return self._get_simple_size_calculation(size_field, force, "length") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string @@ -1191,13 +1262,14 @@ class FixedArrayBytesType(TypeInfo): @property def encode_content(self) -> str: + max_len = self.array_size if isinstance(self.array_size, int) else None if result := self._encode_bytes_with_precomputed_tag( - f"this->{self.field_name}", f"this->{self.field_name}_len" + f"this->{self.field_name}", f"this->{self.field_name}_len", max_len=max_len ): return result if self.force: - return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" - return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" + return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" + return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);" def dump(self, name: str) -> str: return f"out.append(format_hex_pretty({name}, {name}_len));" @@ -1212,13 +1284,14 @@ class FixedArrayBytesType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # Use the actual length stored in the _len field length_field = f"this->{self.field_name}_len" - field_id_size = self.calculate_field_id_size() - if force: - # For repeated fields, always calculate size (no zero check) - return f"size += ProtoSize::calc_length_force({field_id_size}, {length_field});" - # For non-repeated fields, length already checks for zero - return f"size += ProtoSize::calc_length({field_id_size}, {length_field});" + # When array_size < 128, length varint is always 1 byte + if isinstance(self.array_size, int) and self.array_size < 128: + return self._get_single_byte_varint_size( + length_field, force, extra_expr=length_field + ) + + return self._get_simple_size_calculation(length_field, force, "length") def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -1245,6 +1318,9 @@ class UInt32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: + max_val = self.max_value + if max_val is not None and max_val < 128: + return self._get_single_byte_varint_size(name, force) return self._get_simple_size_calculation(name, force, "uint32") def get_estimated_size(self) -> int: @@ -1264,19 +1340,24 @@ class EnumType(TypeInfo): default_value = "" wire_type = WireType.VARINT # Uses wire type 0 + @property + def max_value(self) -> int | None: + """Get max_value from explicit annotation or auto-derive from enum definition.""" + explicit = super().max_value + if explicit is not None: + return explicit + return _enum_max_values.get(self._field.type_name) + @property def encode_func(self) -> str: return "encode_uint32" @property def encode_content(self) -> str: - if result := self._encode_with_precomputed_tag( - f"static_cast(this->{self.field_name})" - ): - return result + value_expr = f"static_cast(this->{self.field_name})" if self.force: - return f"buffer.{self.encode_func}({self.number}, static_cast(this->{self.field_name}), true);" - return f"buffer.{self.encode_func}({self.number}, static_cast(this->{self.field_name}));" + return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr}, true);" + return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr});" def dump(self, name: str) -> str: return f"out.append_p(proto_enum_to_string<{self.cpp_type}>({name}));" @@ -1286,6 +1367,9 @@ class EnumType(TypeInfo): return f"static_cast<{self.cpp_type}>({value})" def get_size_calculation(self, name: str, force: bool = False) -> str: + max_val = self.max_value + if max_val is not None and max_val < 128: + return self._get_single_byte_varint_size(name, force) return self._get_simple_size_calculation( name, force, "uint32", f"static_cast({name})" ) @@ -1439,11 +1523,13 @@ class FixedArrayRepeatedType(TypeInfo): def _encode_element(self, element: str) -> str: """Helper to generate encode statement for a single element.""" if isinstance(self._ti, EnumType): - return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" # Repeated message elements use encode_sub_message (force=true is default) if isinstance(self._ti, MessageType): - return f"buffer.encode_sub_message({self.number}, {element});" - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" + return ( + f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" + ) @property def cpp_type(self) -> str: @@ -1767,11 +1853,13 @@ class RepeatedTypeInfo(TypeInfo): def _encode_element_call(self, element: str) -> str: """Helper to generate encode call for a single element.""" if isinstance(self._ti, EnumType): - return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" # Repeated message elements use encode_sub_message (force=true is default) if isinstance(self._ti, MessageType): - return f"buffer.encode_sub_message({self.number}, {element});" - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" + return ( + f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" + ) @property def encode_content(self) -> str: @@ -1780,7 +1868,7 @@ class RepeatedTypeInfo(TypeInfo): # Special handling for const char* elements (when container_no_template contains "const char") if "const char" in self._container_no_template: o = f"for (const char *it : *this->{self.field_name}) {{\n" - o += f" buffer.{self._ti.encode_func}({self.number}, it, strlen(it), true);\n" + o += f" ProtoEncode::{self._ti.encode_func}(pos, {self.number}, it, strlen(it), true);\n" else: o = f"for (const auto &it : *this->{self.field_name}) {{\n" o += f" {self._encode_element_call('it')}\n" @@ -1844,17 +1932,27 @@ class RepeatedTypeInfo(TypeInfo): size_expr = f"{name}->size()" if self._use_pointer else f"{name}.size()" o += f" size += {size_expr} * {bytes_per_element};\n" else: - # Other types need the actual value + # Check if inner type produces a constant size (doesn't depend on value) + inner_size = self._ti.get_size_calculation("it", True) + if "it" not in inner_size: + # Constant size per element — use multiply instead of loop + # Extract the constant from "size += N;" + const_val = ( + inner_size.strip().removeprefix("size += ").removesuffix(";") + ) + size_expr = f"{name}->size()" if self._use_pointer else f"{name}.size()" + o += f" size += {size_expr} * {const_val};\n" # Special handling for const char* elements - if self._use_pointer and "const char" in self._container_no_template: + elif self._use_pointer and "const char" in self._container_no_template: field_id_size = self.calculate_field_id_size() o += f" for (const char *it : {container_ref}) {{\n" o += f" size += ProtoSize::calc_length_force({field_id_size}, strlen(it));\n" + o += " }\n" else: auto_ref = "" if self._ti_is_bool else "&" o += f" for (const auto {auto_ref}it : {container_ref}) {{\n" - o += f" {self._ti.get_size_calculation('it', True)}\n" - o += " }\n" + o += f" {inner_size}\n" + o += " }\n" o += "}" return o @@ -2355,15 +2453,19 @@ def build_message_type( # Only generate encode method if this message needs encoding and has fields if needs_encode and encode: - o = f"void {desc.name}::encode(ProtoWriteBuffer &buffer) const {{" - if len(encode) == 1 and len(encode[0]) + len(o) + 3 < 120: - o += f" {encode[0]} }}\n" - else: - o += "\n" - o += indent("\n".join(encode)) + "\n" - o += "}\n" + # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls + encode_debug = [ + line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,") for line in encode + ] + o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" + o += indent("\n".join(encode_debug)) + "\n" + o += " return pos;\n" + o += "}\n" cpp += o - prot = "void encode(ProtoWriteBuffer &buffer) const;" + prot = ( + "uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;" + ) public_content.append(prot) # If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used @@ -2723,6 +2825,11 @@ def main() -> None: file = d.file[0] + # Build enum max value map so EnumType can auto-derive max_value + for enum in file.enum_type: + if not enum.options.deprecated and enum.value: + _enum_max_values[f".{enum.name}"] = max(v.number for v in enum.value) + # Build dynamic ifdef mappings early so we can emit USE_API_VARINT64 before includes enum_ifdef_map, message_ifdef_map, message_source_map, used_messages = ( build_type_usage_map(file) diff --git a/script/ci-custom.py b/script/ci-custom.py index ad39f92005..1ec3eab3a9 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1006,6 +1006,38 @@ def lint_log_in_header(fname, line, col, content): ) +PACKAGE_BUS_RE = re.compile( + r"^\s+(\w+):\s*!include\s+\S*test_build_components/common/(\w+)/", + re.MULTILINE, +) + + +@lint_content_check(include=["tests/components/*/test.*.yaml"]) +def lint_test_package_key_matches_bus(fname, content): + """Ensure package keys match the common bus directory name. + + For example, a package using uart_115200 includes must use + 'uart_115200' as the key, not 'uart'. + """ + errs: list[tuple[int, int, str]] = [] + for match in PACKAGE_BUS_RE.finditer(content): + pkg_key = match.group(1) + bus_dir = match.group(2) + if pkg_key != bus_dir: + lineno = content.count("\n", 0, match.start()) + 1 + errs.append( + ( + lineno, + 1, + f"Package key {highlight(pkg_key)} does not match bus directory " + f"{highlight(bus_dir)}. The package key must match the directory " + f"name under tests/test_build_components/common/. " + f"Change {highlight(pkg_key)} to {highlight(bus_dir)}.", + ) + ) + return errs + + @lint_content_find_check( "FINAL_VALIDATE_SCHEMA", include=["esphome/core/*.py"], diff --git a/script/helpers.py b/script/helpers.py index 290dcadf0b..c9c550d889 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -15,8 +15,6 @@ from typing import Any import colorama -from esphome.loader import get_platform - root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", ".."))) basepath = os.path.join(root_path, "esphome") temp_folder = os.path.join(root_path, ".temp") @@ -644,7 +642,7 @@ def get_all_dependencies( PLATFORM_HOST, ) from esphome.core import CORE - from esphome.loader import get_component + from esphome.loader import get_component, get_platform all_components: set[str] = set(component_names) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 41bbafcd02..df7ad4a28c 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -288,6 +288,9 @@ def merge_component_configs( for pkg_name, pkg_value in list(packages_value.items()): if pkg_name in common_bus_packages: continue + # Resolve deferred !include files before checking type + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() if not isinstance(pkg_value, dict): continue # Component-specific package - expand its content into top level @@ -295,6 +298,9 @@ def merge_component_configs( elif isinstance(packages_value, list): # List format - expand all package includes for pkg_value in packages_value: + # Resolve deferred !include files before checking type + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() if not isinstance(pkg_value, dict): continue comp_data = merge_config(comp_data, pkg_value) diff --git a/script/run-in-env.py b/script/run-in-env.py index 886e65db27..9283ba9940 100755 --- a/script/run-in-env.py +++ b/script/run-in-env.py @@ -44,7 +44,8 @@ def find_and_activate_virtualenv(): def run_command(): # Execute the remaining arguments in the new environment if len(sys.argv) > 1: - subprocess.run(sys.argv[1:], check=False, close_fds=False) + result = subprocess.run(sys.argv[1:], check=False, close_fds=False) + sys.exit(result.returncode) else: print( "No command provided to run in the virtual environment.", diff --git a/tests/benchmarks/components/api/bench_list_entities.cpp b/tests/benchmarks/components/api/bench_list_entities.cpp new file mode 100644 index 0000000000..02cef50d70 --- /dev/null +++ b/tests/benchmarks/components/api/bench_list_entities.cpp @@ -0,0 +1,235 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/light/color_mode.h" + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// --- ListEntitiesSensorResponse --- + +static ListEntitiesSensorResponse make_sensor_response() { + ListEntitiesSensorResponse msg; + msg.object_id = StringRef::from_lit("living_room_temperature"); + msg.key = 0x12345678; + msg.name = StringRef::from_lit("Living Room Temperature"); +#ifdef USE_ENTITY_ICON + msg.icon = StringRef::from_lit("mdi:thermometer"); +#endif + msg.entity_category = enums::ENTITY_CATEGORY_NONE; + msg.disabled_by_default = false; + msg.unit_of_measurement = StringRef::from_lit("°C"); + msg.accuracy_decimals = 1; + msg.force_update = false; + msg.device_class = StringRef::from_lit("temperature"); + msg.state_class = enums::STATE_CLASS_MEASUREMENT; +#ifdef USE_DEVICES + msg.device_id = 1; +#endif + return msg; +} + +static void CalculateSize_ListEntitiesSensorResponse(benchmark::State &state) { + auto msg = make_sensor_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_ListEntitiesSensorResponse); + +static void Encode_ListEntitiesSensorResponse(benchmark::State &state) { + auto msg = make_sensor_response(); + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ListEntitiesSensorResponse); + +static void CalcAndEncode_ListEntitiesSensorResponse(benchmark::State &state) { + auto msg = make_sensor_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_ListEntitiesSensorResponse); + +// --- ListEntitiesBinarySensorResponse --- + +static ListEntitiesBinarySensorResponse make_binary_sensor_response() { + ListEntitiesBinarySensorResponse msg; + msg.object_id = StringRef::from_lit("front_door_contact"); + msg.key = 0xAABBCCDD; + msg.name = StringRef::from_lit("Front Door Contact"); +#ifdef USE_ENTITY_ICON + msg.icon = StringRef::from_lit("mdi:door"); +#endif + msg.entity_category = enums::ENTITY_CATEGORY_NONE; + msg.disabled_by_default = false; + msg.device_class = StringRef::from_lit("door"); + msg.is_status_binary_sensor = false; +#ifdef USE_DEVICES + msg.device_id = 2; +#endif + return msg; +} + +static void CalculateSize_ListEntitiesBinarySensorResponse(benchmark::State &state) { + auto msg = make_binary_sensor_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_ListEntitiesBinarySensorResponse); + +static void Encode_ListEntitiesBinarySensorResponse(benchmark::State &state) { + auto msg = make_binary_sensor_response(); + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ListEntitiesBinarySensorResponse); + +static void CalcAndEncode_ListEntitiesBinarySensorResponse(benchmark::State &state) { + auto msg = make_binary_sensor_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse); + +// --- ListEntitiesLightResponse --- + +static light::ColorModeMask light_color_modes; +static FixedVector light_effects; + +static ListEntitiesLightResponse make_light_response() { + // Initialize static data on first call + static bool initialized = false; + if (!initialized) { + light_color_modes.insert(light::ColorMode::RGB_WHITE); + light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE); + light_effects.init(3); + light_effects.push_back("None"); + light_effects.push_back("Rainbow"); + light_effects.push_back("Strobe"); + initialized = true; + } + + ListEntitiesLightResponse msg; + msg.object_id = StringRef::from_lit("kitchen_ceiling_light"); + msg.key = 0x55667788; + msg.name = StringRef::from_lit("Kitchen Ceiling Light"); +#ifdef USE_ENTITY_ICON + msg.icon = StringRef::from_lit("mdi:ceiling-light"); +#endif + msg.entity_category = enums::ENTITY_CATEGORY_NONE; + msg.disabled_by_default = false; + msg.supported_color_modes = &light_color_modes; + msg.min_mireds = 153.0f; + msg.max_mireds = 500.0f; + msg.effects = &light_effects; +#ifdef USE_DEVICES + msg.device_id = 3; +#endif + return msg; +} + +static void CalculateSize_ListEntitiesLightResponse(benchmark::State &state) { + auto msg = make_light_response(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_ListEntitiesLightResponse); + +static void Encode_ListEntitiesLightResponse(benchmark::State &state) { + auto msg = make_light_response(); + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ListEntitiesLightResponse); + +static void CalcAndEncode_ListEntitiesLightResponse(benchmark::State &state) { + auto msg = make_light_response(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_ListEntitiesLightResponse); + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 0caa50c748..74c640a093 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -75,7 +75,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { buffer.clear(); - MessageInfo messages[5] = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}}; + MessageInfo messages[5] = {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}; for (int j = 0; j < 5; j++) { uint16_t offset = buffer.size(); @@ -89,7 +89,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { ProtoWriteBuffer writer(&buffer, offset + padding); msg.encode(writer); - messages[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size); + messages[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size, padding); } helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); diff --git a/tests/benchmarks/components/button/__init__.py b/tests/benchmarks/components/button/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/button/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/button/bench_button.cpp b/tests/benchmarks/components/button/bench_button.cpp new file mode 100644 index 0000000000..82f76961c9 --- /dev/null +++ b/tests/benchmarks/components/button/bench_button.cpp @@ -0,0 +1,55 @@ +#include + +#include "esphome/components/button/button.h" + +namespace esphome::button::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Minimal Button for benchmarking — press_action() is a no-op. +class BenchButton : public Button { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void press_action() override {} +}; + +// --- Button::press() --- +// Measures: ESP_LOGD + press_action() + callback dispatch. + +static void ButtonPress(benchmark::State &state) { + BenchButton button; + button.configure("test_button"); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + button.press(); + } + benchmark::DoNotOptimize(&button); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ButtonPress); + +// --- Button::press() with callback --- +// Measures callback dispatch overhead. + +static void ButtonPress_WithCallback(benchmark::State &state) { + BenchButton button; + button.configure("test_button"); + + uint64_t callback_count = 0; + button.add_on_press_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + button.press(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ButtonPress_WithCallback); + +} // namespace esphome::button::benchmarks diff --git a/tests/benchmarks/components/button/benchmark.yaml b/tests/benchmarks/components/button/benchmark.yaml new file mode 100644 index 0000000000..75f089f793 --- /dev/null +++ b/tests/benchmarks/components/button/benchmark.yaml @@ -0,0 +1 @@ +button: diff --git a/tests/benchmarks/components/number/__init__.py b/tests/benchmarks/components/number/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/number/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/number/bench_number.cpp b/tests/benchmarks/components/number/bench_number.cpp new file mode 100644 index 0000000000..57a73930b2 --- /dev/null +++ b/tests/benchmarks/components/number/bench_number.cpp @@ -0,0 +1,121 @@ +#include + +#include "esphome/components/number/number.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Number for benchmarking — control() publishes the value back. +class BenchNumber : public number::Number { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void control(float value) override { this->publish_state(value); } +}; + +// Helper to create a typical number entity for benchmarks. +static void setup_number(BenchNumber &number) { + number.configure("test_number"); + number.traits.set_min_value(0.0f); + number.traits.set_max_value(100.0f); + number.traits.set_step(1.0f); + number.traits.set_mode(number::NUMBER_MODE_SLIDER); +} + +// --- Number::publish_state() --- +// Measures the publish path: set_has_state, store value, callback dispatch. + +static void NumberPublish_State(benchmark::State &state) { + BenchNumber number; + setup_number(number); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.publish_state(static_cast(i % 100)); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberPublish_State); + +// --- Number::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void NumberPublish_WithCallback(benchmark::State &state) { + BenchNumber number; + setup_number(number); + + uint64_t callback_count = 0; + number.add_on_state_callback([&callback_count](float) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.publish_state(static_cast(i % 100)); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberPublish_WithCallback); + +// --- NumberCall::perform() set value --- +// The most common number call — setting an absolute value. +// Exercises: validation against min/max, control() dispatch. + +static void NumberCall_SetValue(benchmark::State &state) { + BenchNumber number; + setup_number(number); + number.publish_state(50.0f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float val = static_cast(i % 100); + number.make_call().set_value(val).perform(); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberCall_SetValue); + +// --- NumberCall::perform() increment --- +// Exercises: state read, step arithmetic, max clamping. + +static void NumberCall_Increment(benchmark::State &state) { + BenchNumber number; + setup_number(number); + number.publish_state(0.0f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.make_call().number_increment(true).perform(); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberCall_Increment); + +// --- NumberCall::perform() decrement --- +// Exercises: state read, step arithmetic, min clamping. + +static void NumberCall_Decrement(benchmark::State &state) { + BenchNumber number; + setup_number(number); + number.publish_state(100.0f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.make_call().number_decrement(true).perform(); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberCall_Decrement); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/number/benchmark.yaml b/tests/benchmarks/components/number/benchmark.yaml new file mode 100644 index 0000000000..f435661270 --- /dev/null +++ b/tests/benchmarks/components/number/benchmark.yaml @@ -0,0 +1 @@ +number: diff --git a/tests/benchmarks/components/select/__init__.py b/tests/benchmarks/components/select/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/select/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/select/bench_select.cpp b/tests/benchmarks/components/select/bench_select.cpp new file mode 100644 index 0000000000..8e047d9151 --- /dev/null +++ b/tests/benchmarks/components/select/bench_select.cpp @@ -0,0 +1,157 @@ +#include + +#include "esphome/components/select/select.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Select for benchmarking — control() publishes directly by index. +class BenchSelect : public select::Select { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void control(size_t index) override { this->publish_state(index); } +}; + +// Helper to create a select with the given options. +static void setup_select(BenchSelect &select, const char *name, std::initializer_list options) { + select.configure(name); + select.traits.set_options(options); + select.publish_state(size_t(0)); +} + +// --- Select::publish_state(size_t) --- +// The fast path: publish by index, no string lookup. + +static void SelectPublish_ByIndex(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.publish_state(static_cast(i % 4)); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectPublish_ByIndex); + +// --- Select::publish_state(const char *) --- +// The string path: requires index_of() lookup via strncmp. + +static void SelectPublish_ByString(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + const char *options[] = {"off", "still", "move", "still+move"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.publish_state(options[i % 4]); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectPublish_ByString); + +// --- Select::publish_state() with callback --- +// Measures callback dispatch overhead on the index path. + +static void SelectPublish_WithCallback(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + uint64_t callback_count = 0; + select.add_on_state_callback([&callback_count](size_t) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.publish_state(static_cast(i % 4)); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectPublish_WithCallback); + +// --- SelectCall::perform() set by index --- +// The fast call path — no string matching needed. + +static void SelectCall_SetByIndex(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().set_index(i % 4).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_SetByIndex); + +// --- SelectCall::perform() set by option string --- +// Exercises the string lookup path through index_of(). + +static void SelectCall_SetByOption(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + const char *options[] = {"off", "still", "move", "still+move"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().set_option(options[i % 4]).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_SetByOption); + +// --- SelectCall::perform() next with cycling --- +// Exercises the navigation path through active_index_. + +static void SelectCall_NextCycle(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().select_next(true).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_NextCycle); + +// --- SelectCall with 10 options (string lookup) --- +// Worst-case string matching with more options. + +static void SelectCall_SetByOption_10Options(benchmark::State &state) { + BenchSelect select; + setup_select( + select, "test_select", + {"off", "still", "move", "still+move", "custom1", "custom2", "custom3", "custom4", "custom5", "custom6"}); + + // Pick options spread across the list to exercise different search depths + const char *picks[] = {"off", "custom3", "custom6", "move"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().set_option(picks[i % 4]).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_SetByOption_10Options); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/select/benchmark.yaml b/tests/benchmarks/components/select/benchmark.yaml new file mode 100644 index 0000000000..d336a348a0 --- /dev/null +++ b/tests/benchmarks/components/select/benchmark.yaml @@ -0,0 +1 @@ +select: diff --git a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp index e4aa397690..e6dc783567 100644 --- a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp +++ b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp @@ -56,8 +56,8 @@ static void SensorFilter_Chain3(benchmark::State &state) { Sensor sensor; sensor.add_filters({ - new OffsetFilter(1.0f), - new MultiplyFilter(2.0f), + new OffsetFilter([]() -> float { return 1.0f; }), + new MultiplyFilter([]() -> float { return 2.0f; }), new SlidingWindowMovingAverageFilter(5, 1, 1), }); diff --git a/tests/benchmarks/components/switch/__init__.py b/tests/benchmarks/components/switch/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/switch/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/switch/bench_switch.cpp b/tests/benchmarks/components/switch/bench_switch.cpp new file mode 100644 index 0000000000..d948f080ad --- /dev/null +++ b/tests/benchmarks/components/switch/bench_switch.cpp @@ -0,0 +1,137 @@ +#include + +#include "esphome/components/switch/switch.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Switch for benchmarking — write_state() publishes directly. +class BenchSwitch : public switch_::Switch { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void write_state(bool state) override { this->publish_state(state); } +}; + +// --- Switch::publish_state() alternating --- +// Forces state change every call, exercising the full publish path. + +static void SwitchPublish_Alternating(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(i % 2 == 0); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_Alternating); + +// --- Switch::publish_state() no change --- +// Tests the deduplication fast path in publish_dedup_. + +static void SwitchPublish_NoChange(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(true); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_NoChange); + +// --- Switch::publish_state() with callback --- +// Measures callback dispatch overhead on state changes. + +static void SwitchPublish_WithCallback(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + + uint64_t callback_count = 0; + sw.add_on_state_callback([&callback_count](bool) { callback_count++; }); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(i % 2 == 0); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_WithCallback); + +// --- Switch::turn_on() / turn_off() --- +// The front-end call path: turn_on → write_state → publish_state. + +static void SwitchTurnOn(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.turn_on(); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchTurnOn); + +// --- Switch::toggle() alternating --- +// Exercises the toggle path which reads current state to determine target. + +static void SwitchToggle(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.toggle(); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchToggle); + +// --- Switch::publish_state() inverted --- +// Verifies the inversion path doesn't add significant overhead. + +static void SwitchPublish_Inverted(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.set_inverted(true); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(i % 2 == 0); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_Inverted); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/switch/benchmark.yaml b/tests/benchmarks/components/switch/benchmark.yaml new file mode 100644 index 0000000000..c637b3dc89 --- /dev/null +++ b/tests/benchmarks/components/switch/benchmark.yaml @@ -0,0 +1 @@ +switch: diff --git a/tests/benchmarks/components/text_sensor/__init__.py b/tests/benchmarks/components/text_sensor/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/text_sensor/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/text_sensor/bench_text_sensor.cpp b/tests/benchmarks/components/text_sensor/bench_text_sensor.cpp new file mode 100644 index 0000000000..0ac88f79c1 --- /dev/null +++ b/tests/benchmarks/components/text_sensor/bench_text_sensor.cpp @@ -0,0 +1,108 @@ +#include + +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::text_sensor::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// --- publish_state(const char *) with short string, value changes each time --- +// Exercises: memcmp check (mismatch), string assign, callback dispatch. + +static void TextSensorPublish_Short_Changing(benchmark::State &state) { + TextSensor sensor; + + // Pre-populate with different short strings + const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_Short_Changing); + +// --- publish_state(const char *) with short string, same value (dedup path) --- +// Exercises: memcmp check (match), skips string assign. + +static void TextSensorPublish_Short_NoChange(benchmark::State &state) { + TextSensor sensor; + sensor.publish_state("192.168.1.100"); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state("192.168.1.100"); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_Short_NoChange); + +// --- publish_state with longer string (firmware version, MAC address) --- +// Exercises: memcmp on longer strings, string assign with potential realloc. + +static void TextSensorPublish_Long_Changing(benchmark::State &state) { + TextSensor sensor; + + const char *values[] = { + "2025.12.0-dev (Jan 15 2025, 10:30:00)", + "2025.12.1-dev (Feb 20 2025, 14:45:00)", + "2025.12.2-dev (Mar 10 2025, 08:15:00)", + "2025.12.3-dev (Apr 5 2025, 16:00:00)", + }; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_Long_Changing); + +// --- publish_state with callback --- +// Measures callback dispatch overhead for text sensors. + +static void TextSensorPublish_WithCallback(benchmark::State &state) { + TextSensor sensor; + + uint64_t callback_count = 0; + sensor.add_on_state_callback([&callback_count](const std::string &) { callback_count++; }); + + const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4]); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_WithCallback); + +// --- publish_state(const char *, size_t) direct --- +// The lowest-level overload, avoids strlen. + +static void TextSensorPublish_WithLen(benchmark::State &state) { + TextSensor sensor; + + static constexpr const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"}; + static constexpr size_t lens[] = {11, 11, 11, 11}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4], lens[i % 4]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_WithLen); + +} // namespace esphome::text_sensor::benchmarks diff --git a/tests/benchmarks/components/text_sensor/benchmark.yaml b/tests/benchmarks/components/text_sensor/benchmark.yaml new file mode 100644 index 0000000000..7bcb7de1c8 --- /dev/null +++ b/tests/benchmarks/components/text_sensor/benchmark.yaml @@ -0,0 +1 @@ +text_sensor: diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index c6e1e6930e..d9a9d158a3 100644 --- a/tests/benchmarks/core/bench_helpers.cpp +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -10,7 +10,6 @@ namespace esphome::benchmarks { static constexpr int kInnerIterations = 2000; // --- random_float() --- -// Ported from ol.yaml:148 "Random Float Benchmark" static void RandomFloat(benchmark::State &state) { for (auto _ : state) { @@ -38,4 +37,274 @@ static void RandomUint32(benchmark::State &state) { } BENCHMARK(RandomUint32); +// --- format_hex_to() - 6 bytes (MAC address sized) --- + +static void FormatHexTo_6Bytes(benchmark::State &state) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45}; + char buffer[13]; // 6 * 2 + 1 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_to(buffer, data, 6); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexTo_6Bytes); + +// --- format_hex_to() - 16 bytes (UUID sized) --- + +static void FormatHexTo_16Bytes(benchmark::State &state) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, + 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10}; + char buffer[33]; // 16 * 2 + 1 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_to(buffer, data, 16); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexTo_16Bytes); + +// --- format_hex_to() - 100 bytes (large payload) --- + +static void FormatHexTo_100Bytes(benchmark::State &state) { + uint8_t data[100]; + for (int i = 0; i < 100; i++) { + data[i] = static_cast(i); + } + char buffer[201]; // 100 * 2 + 1 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_to(buffer, data, 100); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexTo_100Bytes); + +// --- format_hex_pretty_to() - 6 bytes with ':' separator --- + +static void FormatHexPrettyTo_6Bytes(benchmark::State &state) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45}; + char buffer[18]; // 6 * 3 + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_hex_pretty_to(buffer, data, 6); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatHexPrettyTo_6Bytes); + +// --- format_mac_addr_upper() --- + +static void FormatMacAddrUpper(benchmark::State &state) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + format_mac_addr_upper(mac, buffer); + } + benchmark::DoNotOptimize(buffer); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FormatMacAddrUpper); + +// --- fnv1_hash() - short string --- + +static void Fnv1Hash_Short(benchmark::State &state) { + const char *str = "sensor.temperature"; + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash(str); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1Hash_Short); + +// --- fnv1_hash() - long string --- + +static void Fnv1Hash_Long(benchmark::State &state) { + const char *str = "binary_sensor.living_room_motion_sensor_occupancy_detected"; + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash(str); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1Hash_Long); + +// --- fnv1a_hash() - short string --- +// Use DoNotOptimize on the input pointer to prevent constexpr evaluation + +static void Fnv1aHash_Short(benchmark::State &state) { + const char *str = "sensor.temperature"; + benchmark::DoNotOptimize(str); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1a_hash(str); + benchmark::ClobberMemory(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1aHash_Short); + +// --- fnv1a_hash() - long string --- + +static void Fnv1aHash_Long(benchmark::State &state) { + const char *str = "binary_sensor.living_room_motion_sensor_occupancy_detected"; + benchmark::DoNotOptimize(str); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1a_hash(str); + benchmark::ClobberMemory(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1aHash_Long); + +// --- fnv1_hash_object_id() - typical entity name --- + +static void Fnv1HashObjectId(benchmark::State &state) { + char name[] = "Living Room Temperature Sensor"; + size_t len = sizeof(name) - 1; + benchmark::DoNotOptimize(name); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash_object_id(name, len); + benchmark::ClobberMemory(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Fnv1HashObjectId); + +// --- parse_hex() - 6 bytes from string --- + +static void ParseHex_6Bytes(benchmark::State &state) { + const char *hex_str = "ABCDEF012345"; + uint8_t data[6]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + parse_hex(hex_str, data, 6); + } + benchmark::DoNotOptimize(data); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ParseHex_6Bytes); + +// --- parse_hex() - 16 bytes from string --- + +static void ParseHex_16Bytes(benchmark::State &state) { + const char *hex_str = "ABCDEF0123456789FEDCBA9876543210"; + uint8_t data[16]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + parse_hex(hex_str, data, 16); + } + benchmark::DoNotOptimize(data); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ParseHex_16Bytes); + +// --- crc8() - 8 bytes --- + +static void CRC8_8Bytes(benchmark::State &state) { + const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + for (auto _ : state) { + uint8_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= crc8(data, 8); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CRC8_8Bytes); + +// --- crc16() - 8 bytes --- + +static void CRC16_8Bytes(benchmark::State &state) { + const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + for (auto _ : state) { + uint16_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= crc16(data, 8); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CRC16_8Bytes); + +// --- value_accuracy_to_buf() - typical sensor value --- + +static void ValueAccuracyToBuf(benchmark::State &state) { + char raw_buf[VALUE_ACCURACY_MAX_LEN] = {}; + std::span buf(raw_buf); + float value = 23.456f; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + value_accuracy_to_buf(buf, value, 2); + } + benchmark::DoNotOptimize(raw_buf); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ValueAccuracyToBuf); + +// --- int8_to_str() --- + +static void Int8ToStr(benchmark::State &state) { + char buffer[5] = {}; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + int8_to_str(buffer, static_cast(i & 0xFF)); + benchmark::DoNotOptimize(buffer); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Int8ToStr); + +// --- base64_decode() - into pre-allocated buffer --- + +static void Base64Decode_32Bytes(benchmark::State &state) { + // 32 bytes encoded = 44 base64 chars + const uint8_t encoded[] = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGx0eHw=="; + size_t encoded_len = 44; + uint8_t output[32]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + base64_decode(encoded, encoded_len, output, sizeof(output)); + } + benchmark::DoNotOptimize(output); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Base64Decode_32Bytes); + } // namespace esphome::benchmarks diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 9357734cc8..214fe0e4b8 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -8,7 +8,24 @@ namespace esphome::benchmarks { // Inner iteration count to amortize CodSpeed instrumentation overhead. // Without this, the ~60ns per-iteration valgrind start/stop cost dominates // sub-microsecond benchmarks. -static constexpr int kInnerIterations = 2000; +// Must be divisible by all batch sizes used below (3, 10) to avoid +// pool imbalance at iteration boundaries that causes spurious malloc. +static constexpr int kInnerIterations = 2100; + +// Warm the scheduler pool by registering and replacing items twice. +// The first batch allocates fresh items; the second batch cancels them and +// populates the recycling pool with the cancelled items from the first batch. +static void warm_pool(Scheduler &scheduler, Component *component, int batch_size, uint32_t delay) { + uint32_t now = millis(); + for (int i = 0; i < batch_size; i++) { + scheduler.set_timeout(component, static_cast(i), delay, []() {}); + } + scheduler.call(++now); + for (int i = 0; i < batch_size; i++) { + scheduler.set_timeout(component, static_cast(i), delay, []() {}); + } + scheduler.call(++now); +} // --- Scheduler fast path: no work to do --- @@ -83,11 +100,21 @@ static void Scheduler_SetTimeout(benchmark::State &state) { Scheduler scheduler; Component dummy_component; + // Register 3 timeouts then call() — realistic worst case where multiple + // components schedule in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % 5), 1000, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } } - scheduler.process_to_add(); + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -99,22 +126,22 @@ BENCHMARK(Scheduler_SetTimeout); static void Scheduler_SetInterval(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Number of distinct interval keys; controls how many unique timers exist - // simultaneously and the drain cadence for process_to_add(). - static constexpr int kKeyCount = 5; + // Register 3 intervals then call() — realistic worst case where multiple + // components schedule in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_interval(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); - // Drain to_add_ periodically to reflect production behavior where - // process_to_add() runs each main loop iteration. Without this, - // cancelled items accumulate in to_add_ causing O(n²) scan cost. - if ((i + 1) % kKeyCount == 0) { - scheduler.process_to_add(); + scheduler.set_interval(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); } } - // Final drain in case kInnerIterations is not a multiple of 5 - scheduler.process_to_add(); + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -128,16 +155,79 @@ static void Scheduler_Defer(benchmark::State &state) { Component dummy_component; // defer() is Component::defer which calls set_timeout(delay=0). - // Call set_timeout directly since defer() is protected. + // Component::defer(func) passes nullptr as the name, which skips + // cancel_item_locked_ entirely — matching production behavior where + // defers are anonymous fire-and-forget callbacks. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 0); for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % 5), 0, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(nullptr), 0, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } } - scheduler.process_to_add(); + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } BENCHMARK(Scheduler_Defer); +// --- Scheduler: defer with same ID (cancel-and-replace pattern) --- + +static void Scheduler_Defer_SameID(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Measures defer with a fixed numeric ID — each call cancels the previous + // pending defer before adding the new one. This is the pattern used by + // components that defer work but want to coalesce rapid updates. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 0); + for (auto _ : state) { + uint32_t now = millis(); + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(0), 0, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Defer_SameID); + +// --- Scheduler: set_timeout with batch size exceeding pool (cliff test) --- + +static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure + // the performance cliff when the recycling pool is exhausted and items + // must be malloc'd/freed. + static constexpr int kBatchSize = 10; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); + for (auto _ : state) { + uint32_t now = millis(); + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_SetTimeout_ExceedPool); + } // namespace esphome::benchmarks diff --git a/tests/component_tests/display/__init__.py b/tests/component_tests/display/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py new file mode 100644 index 0000000000..e569754494 --- /dev/null +++ b/tests/component_tests/display/test_display_metadata.py @@ -0,0 +1,81 @@ +"""Tests for display component metadata functions.""" + +from unittest.mock import patch + +from esphome.components.display import ( + DisplayMetaData, + add_metadata, + get_all_display_metadata, + get_display_metadata, +) +from esphome.cpp_generator import MockObj + + +def test_add_metadata_with_string_id(): + """Test adding metadata with a plain string ID.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("my_display", 320, 240, True) + meta = get_display_metadata("my_display") + assert meta == DisplayMetaData( + width=320, height=240, has_writer=True, has_hardware_rotation=False + ) + + +def test_add_metadata_with_mockobj_id(): + """Test adding metadata with a MockObj ID (converted via str()).""" + with patch("esphome.components.display.CORE.data", {}): + mock_id = MockObj("my_display_obj") + add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True) + meta = get_display_metadata("my_display_obj") + assert meta == DisplayMetaData( + width=480, height=320, has_writer=False, has_hardware_rotation=True + ) + + +def test_add_metadata_hardware_rotation_default(): + """Test that has_hardware_rotation defaults to False.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("disp", 128, 64, False) + meta = get_display_metadata("disp") + assert meta.has_hardware_rotation is False + + +def test_get_display_metadata_missing_returns_none(): + """Test that querying a non-existent ID returns None.""" + with patch("esphome.components.display.CORE.data", {}): + data = get_display_metadata("no_such_display") + assert data.width == 0 + assert data.height == 0 + assert data.has_writer is False + assert data.has_hardware_rotation is False + + +def test_add_multiple_displays(): + """Test adding metadata for multiple displays.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("disp_a", 320, 240, True) + add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 2 + assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True) + + +def test_add_metadata_overwrites_existing(): + """Test that adding metadata for the same ID overwrites the previous entry.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("disp", 320, 240, True) + add_metadata("disp", 640, 480, False, has_hardware_rotation=True) + meta = get_display_metadata("disp") + assert meta == DisplayMetaData(640, 480, False, True) + + +def test_metadata_is_frozen(): + """Test that DisplayMetaData instances are immutable (frozen dataclass).""" + meta = DisplayMetaData(320, 240, True, False) + try: + meta.width = 640 + assert False, "Expected FrozenInstanceError" + except AttributeError: + pass diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 1ae8cc644e..119bbf7fea 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -133,6 +133,6 @@ def test_code_generation( assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp - assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp + assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp # assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index eddf0987d0..082a9e55f2 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,6 +1,7 @@ """Tests for mpip_spi configuration validation.""" from collections.abc import Callable, Generator +from unittest import mock import pytest @@ -12,6 +13,16 @@ from esphome.core import CORE from esphome.pins import gpio_pin_schema +@pytest.fixture(autouse=True) +def mock_spi_final_validate(): + """Mock spi.final_validate_device_schema since unit tests have no real SPI bus config.""" + with mock.patch( + "esphome.components.spi.final_validate_device_schema", + return_value=lambda config: None, + ): + yield + + @pytest.fixture def choose_variant_with_pins() -> Generator[Callable[[list], None]]: """ diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py new file mode 100644 index 0000000000..c11c7816e4 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -0,0 +1,202 @@ +"""Tests for display metadata created by mipi_spi component.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.components.display import ( + DisplayMetaData, + get_all_display_metadata, + get_display_metadata, +) +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + get_instance, +) +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config): + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_metadata_native_quad_default_test_card( + set_core_config: SetCoreConfigCallable, +) -> None: + """A quad-mode display with no explicit drawing gets a test card from final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + config = validated_config({"model": "JC3636W518"}) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.width == 360 + assert meta.height == 360 + # final validation auto-enables show_test_card when no drawing methods are configured + assert meta.has_writer is True + assert meta.has_hardware_rotation is True + + +def test_metadata_single_mode_with_dc_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """A single-mode display with no explicit drawing gets a test card from final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = validated_config( + { + "model": "ST7735", + "dc_pin": 18, + } + ) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.width == 128 + assert meta.height == 160 + assert meta.has_writer is True + assert meta.has_hardware_rotation is True + + +def test_metadata_custom_dimensions( + set_core_config: SetCoreConfigCallable, +) -> None: + """A custom model picks up explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 480, "height": 320}, + "init_sequence": [[0xA0, 0x01]], + } + ) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.width == 480 + assert meta.height == 320 + # final validation auto-enables show_test_card + assert meta.has_writer is True + assert meta.has_hardware_rotation is True + + +def test_metadata_with_test_card_has_writer( + set_core_config: SetCoreConfigCallable, +) -> None: + """When show_test_card is enabled, has_writer should be True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 240, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "show_test_card": True, + } + ) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_no_swap_xy_not_full_hardware_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model that disables swap_xy should report has_hardware_rotation=False.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + config = validated_config({"model": "JC3248W535"}) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.has_hardware_rotation is False + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, +) -> None: + """Multiple displays each get their own metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config_a = validated_config( + { + "id": "disp_a", + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + } + ) + config_b = validated_config( + { + "id": "disp_b", + "model": "custom", + "dc_pin": 19, + "dimensions": {"width": 128, "height": 64}, + "init_sequence": [[0xA0, 0x01]], + } + ) + get_instance(config_a) + get_instance(config_b) + + all_meta = get_all_display_metadata() + # final validation auto-enables show_test_card for both + assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True) + + +def test_metadata_via_code_generation_native( + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], +) -> None: + """Full code generation for native.yaml should produce correct metadata.""" + generate_main(component_fixture_path("native.yaml")) + all_meta = get_all_display_metadata() + # native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + assert meta == DisplayMetaData( + width=360, height=360, has_writer=True, has_hardware_rotation=True + ) + + +def test_metadata_via_code_generation_lvgl( + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], +) -> None: + """Full code generation for lvgl.yaml should produce correct metadata.""" + generate_main(component_fixture_path("lvgl.yaml")) + all_meta = get_all_display_metadata() + # lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + assert meta == DisplayMetaData( + width=128, height=160, has_writer=False, has_hardware_rotation=True + ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index bae39d3879..4873892a8d 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -204,11 +204,6 @@ def test_transform_and_init_sequence_errors( r"extra keys not allowed @ data\['brightness'\]", id="brightness_not_supported", ), - pytest.param( - {"model": "T-DISPLAY-S3-PRO"}, - "PSRAM is required for this display", - id="psram_required", - ), ], ) def test_esp32s3_specific_errors( @@ -319,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -335,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/components/ags10/common.yaml b/tests/components/ags10/common.yaml index 0551871e59..8009c56295 100644 --- a/tests/components/ags10/common.yaml +++ b/tests/components/ags10/common.yaml @@ -4,3 +4,14 @@ sensor: tvoc: name: AGS10 TVOC update_interval: 60s + +button: + - platform: template + name: "Test AGS10 Actions" + on_press: + - ags10.set_zero_point: + id: ags10_1 + mode: CURRENT_VALUE + - ags10.new_i2c_address: + id: ags10_1 + address: 0x1A diff --git a/tests/components/at581x/common.yaml b/tests/components/at581x/common.yaml index 425be47c42..dfb3a9a05e 100644 --- a/tests/components/at581x/common.yaml +++ b/tests/components/at581x/common.yaml @@ -12,6 +12,11 @@ esphome: trigger_keep: 10s stage_gain: 3 power_consumption: 70uA + - at581x.settings: + id: waveradar + frequency: !lambda "return 5800;" + poweron_selfcheck_time: !lambda "return 2000;" + power_consumption: !lambda "return 70;" - at581x.reset: id: waveradar diff --git a/tests/components/dsmr/common.yaml b/tests/components/dsmr/common.yaml index d11ce37d59..962800d7b0 100644 --- a/tests/components/dsmr/common.yaml +++ b/tests/components/dsmr/common.yaml @@ -13,3 +13,4 @@ dsmr: request_pin: ${request_pin} request_interval: 20s receive_timeout: 100ms + thermal_mbus_id: 3 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..a0784fcd53 --- /dev/null +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_115200: !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..80a2cb2fc0 --- /dev/null +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_115200: !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..410c579d4b --- /dev/null +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 9593d0f6f0..bf6053c78b 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -78,6 +78,7 @@ display: model: seeed-reterminal-e1002 - platform: epaper_spi model: seeed-ee04-mono-4.26 + full_update_every: 10 # Override pins to avoid conflict with other display configs busy_pin: 43 dc_pin: 42 diff --git a/tests/components/esp32/dummy_signing_key.pem b/tests/components/esp32/dummy_signing_key.pem new file mode 100644 index 0000000000..a74231d590 --- /dev/null +++ b/tests/components/esp32/dummy_signing_key.pem @@ -0,0 +1,41 @@ +*** DO NOT USE THIS KEY...EVER *** +-----BEGIN RSA PRIVATE KEY----- +MIIG5AIBAAKCAYEA0J665DlxzUzzouzH96fxqXybEfFU7H1oSf2fUHwoNMgUG7Vc +SHxuFJkpsUnxg9br09/v5THOXfUj5t/Arog6FGiL7i0HXCYDMnSn2EzQWR+DY2Qj +C3YzTLvcOQ40gFjDWfzAheAMCQmc5xQeB3YmaXQf+fUWH/PfFs9Pm+L92YTv2XC1 +B2q5s8K8hUWghO472A+UMrreuDcltNJ+TbuSRHK0NQzKpKo0Vkl4HycczGDgpa8D +h68JL/BKVeJAjKxWd/xcj/FCk661ODXi0esB/mGQP3hAthWpwi+gdkWczWs1Ocr6 +VxKje1zFm9SEq+SmCViPY/Pu8Xs7steqz3b3JtRGtKQE0r3B+hBKI7aRudOZyz0s +kqoL1zYrAWmoTWBqa2tj1ACPqtr2LyHGt2aVrHRQGJf21mPYIy9GIOv+3v3GzIAK +az2B8Z93Bw1biwNZDr1SLNYfQVaJT1hQavmdlvwW8vqLUGDcQlk42yOF6nAmvAPu +Wzxf+QFEtJT6Am65AgMBAAECggGAB0d+mG+LscDtYGI4MQNGaqZLJ+NelfjjPm+v +0yhd48eWcggQPgQ/eA8HFiVRHMtPQ7+U2I+2Fm+zDr+AcuaUdjlWppsiHlxCMMzC +vYiinXV8yWdJVMFNVXBZpRECknbmbBmmYxV3/gm8lJCOYq7D9NqFMhzT5o4FGv/l +VHhlaKVblB/7ZRSbgbL6DoFpMjI42tdiUanVEyLzeR1+JDq3BhXlhVNar8ezl04t +d5LPDa+UrxtN+XpJTQeqpFgGbhImSxjzCjo0kbGiEx/DwWuFJxguIcDU25sM4g2+ +ivtn7N11U0oaqNwsz7p4cKAm8toJYxxXWZvKdj1kZvCZ+BtyH0/MtOa2Q6v91HOh +zY4KEl5wxQYnxJrgqevSm8rrC51tLOCidZ16cHba8sjrK69xysEazk43roHLFXDp +JpH7Zd8LETjFWGVfUz6vppzkt6mrJk0DNuMLk/UwpPHzW2pu1qDiHPCb9+ra1S9U +t55hT2TBFDcG/NmZZnyHQoh8METhAoHBAPIG0G8Cd4fmkvbgCRLzCIWsH6zGHS9o +80Rj9Gu93B+m/F9GtgyYuX+DKSdMdw3IJamUsBwofT2wynmkuJFhLtD1FmYtlsXf +TWp8g8CfFGrIXDvin5E3heyhvtFiOjXlw0Q8yMmQXr5LF0i3WyFCPQM20ugClB7N +CQBOVAfpVoRU1fA6UjjHibFRwi1b4bLV69QiERPCJfcny/DPkZpu7I1fiINmwzEb +O5mIFo5F4TQADEreWplXEmhEXIzDMFIwEQKBwQDcqimCcO3RSysZMQhhUfk8G19I +yRNwvi2fK5LiGCZMYjeYKqg1rBN4yCf9PTwaqBNRqXTg13Fc7zrOkSI+0oDa4FWI +/kMEztaUK+Kwd2NKc96aXHMBGF+1Sx7Ygnr9e2dyqDqRij2/qlQYY1EDz7cYldaX +YNrXcQQeNJbqydjRDYi+9bI+wDkrK/5PxE1sGmqS1RMKxoJCZmxNiQT3PmXM/oNR +Ev6N9CDklFtClWNcD0Uum+mxNJ53ldZDx4UI/CkCgcEA6R6BI3vX0FHaGureMp9f +BQoulEdbEzBeqPAyHJkKbn50Nf0xGt78RYL7X7v6LI8tH7N1Eho5z/L6g8KSeI2H +/4MiqRaeVEdrFPeMHDvd+aC1noUBt2komS2OU7XuZb3CoHZ/3A4wA9DmQ4dAwr8/ +b1oeOZVKQISzd9T6gYhSajIgwzwZuFESInaitvf6ZDxC49hQZJyr3u05NeFo2Lyh +Iuby4cZYmnMlrBN1zmImseSd8ntL/sjslPvLvVXAtFlRAoHBAIDuG5rPiOTE2sW5 +VIAoeUuZYq8QbX9uXxGlUAkyuw3eRUVvhyD1DduAd30Ljla05bTNIjFNMDtwvBd9 +zViPfiJk+RU2GspwYAfrLGSXHTifQu1GHxwAtcsjvT4b3ujEdckUakQnVbTrPH+T +Z/6mGwEOa3e/a559tj4/0/4TOc/L7J5GyILJpZ2H8uuAcww60xI/1QRywCEz3wve +hzw/BRQlkWyJgJpIjf+Af2IEDy327iExj/WuHPkaXzrzFNQPIQKBwAM6qeNOxrO3 +V91wg4+44FAsOda62fZ0GlCM7ETnEjLbFamtCKEcDNfijwTa54LcZ6yObyutD1RN +dhj4Z6QKuYnsE02agv9CtXdFEVEXaqj4pshdgVOwGK34OidT4yIJQGLrRAQ/JiGH +x6CoGUCNIAq5J08VdosLTD9qdn1zv8USCAP0ReKnRMndTzENLYz9G3nQyHgt5GzI +YoSRtrWnXrQp2Yn3epk74gFAJtKozWNV4Du35FJBjmSeMuRivonNMQ== +-----END RSA PRIVATE KEY----- +*** DO NOT USE THIS KEY...EVER *** diff --git a/tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml b/tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml new file mode 100644 index 0000000000..cf1a54cfa1 --- /dev/null +++ b/tests/components/esp32/test-signed_ota.esp32-s3-idf.yaml @@ -0,0 +1,10 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key.pem + signing_scheme: rsa3072 + +<<: !include common.yaml diff --git a/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml new file mode 100644 index 0000000000..80f166c057 --- /dev/null +++ b/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml @@ -0,0 +1,13 @@ +esp32_hosted: + variant: ESP32C6 + slot: 1 + bus_width: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml new file mode 100644 index 0000000000..a4423140de --- /dev/null +++ b/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml @@ -0,0 +1,15 @@ +esp32_hosted: + type: spi + variant: ESP32C6 + active_high: true + reset_pin: GPIO15 + handshake_pin: GPIO54 + data_ready_pin: GPIO14 + miso_pin: GPIO10 + mosi_pin: GPIO11 + clk_pin: GPIO9 + cs_pin: GPIO53 + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index b724af54e0..bdc478ea03 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -29,6 +29,8 @@ espnow: data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' - espnow.broadcast: data: "Hello, World!" + - espnow.broadcast: + data: "it's a test" - espnow.broadcast: data: [0x01, 0x02, 0x03, 0x04, 0x05] - espnow.broadcast: diff --git a/tests/components/ethernet/common-w6100-rp2040.yaml b/tests/components/ethernet/common-w6100-rp2040.yaml new file mode 100644 index 0000000000..8afbd2d7cd --- /dev/null +++ b/tests/components/ethernet/common-w6100-rp2040.yaml @@ -0,0 +1,18 @@ +ethernet: + type: W6100 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-w6300-rp2040.yaml b/tests/components/ethernet/common-w6300-rp2040.yaml new file mode 100644 index 0000000000..c248bc9810 --- /dev/null +++ b/tests/components/ethernet/common-w6300-rp2040.yaml @@ -0,0 +1,18 @@ +ethernet: + type: W6300 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index 36f1b5365f..f1551fef60 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -1 +1,20 @@ -<<: !include common-w5500.yaml +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + interface: spi2 + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-w6100.rp2040-ard.yaml b/tests/components/ethernet/test-w6100.rp2040-ard.yaml new file mode 100644 index 0000000000..bf119e97c4 --- /dev/null +++ b/tests/components/ethernet/test-w6100.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-w6100-rp2040.yaml diff --git a/tests/components/ethernet/test-w6300.rp2040-ard.yaml b/tests/components/ethernet/test-w6300.rp2040-ard.yaml new file mode 100644 index 0000000000..4fa1bb76f4 --- /dev/null +++ b/tests/components/ethernet/test-w6300.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-w6300-rp2040.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/hdc2080/common.yaml b/tests/components/hdc2080/common.yaml index cb14cb183b..48c6de2cd5 100644 --- a/tests/components/hdc2080/common.yaml +++ b/tests/components/hdc2080/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: hdc2080 + i2c_id: i2c_bus temperature: name: Temperature humidity: diff --git a/tests/components/internal_temperature/test.ln882x-ard.yaml b/tests/components/internal_temperature/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/internal_temperature/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 3a6af93b64..d3565c6c59 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,18 +30,63 @@ binary_sensor: return y; lvgl: + id: lvgl_id + rotation: 90 log_level: debug resume_on_input: true on_pause: - logger.log: LVGL is Paused + - logger.log: LVGL is Paused + - lvgl.display.set_rotation: 90 on_resume: - logger.log: LVGL has resumed + - logger.log: LVGL has resumed + - lvgl.display.set_rotation: + rotation: 0 + lvgl_id: lvgl_id + on_boot: - logger.log: LVGL has started - lvgl.indicator.update: id: meter_arc_indicator start_value: 0 end_value: 180 + on_invalidate_area: + logger.log: Invalidate area + on_resolution_change: + logger.log: Resolution changed + on_color_format_change: + logger.log: Color format changed + on_refr_request: + logger.log: Refresh request + on_refr_start: + logger.log: Refresh start + on_refr_ready: + logger.log: Refresh ready + on_render_start: + logger.log: Render start + on_render_ready: + logger.log: Render ready + on_flush_start: + logger.log: Flush start + on_flush_finish: + logger.log: Flush finish + on_flush_wait_start: + logger.log: Flush wait start + on_flush_wait_finish: + logger.log: Flush wait finish + on_update_layout_complete: + logger.log: Update layout complete + on_vsync: + logger.log: Vsync + on_vsync_request: + logger.log: Vsync request + on_screen_load_start: + logger.log: Screen load start + on_screen_load: + logger.log: Screen loaded + on_screen_unload: + logger.log: Screen unloaded + on_screen_unload_start: + logger.log: Screen unload start bg_color: light_blue bottom_layer: widgets: @@ -49,6 +94,7 @@ lvgl: bg_color: 0x000000 bg_opa: cover theme: + dark_mode: true obj: border_width: 1 @@ -148,6 +194,7 @@ lvgl: text: "Close" on_click: then: + - lvgl.display.set_rotation: 0 - lvgl.widget.hide: message_box - lvgl.style.update: id: style_test @@ -652,14 +699,6 @@ lvgl: logger.log: Child created on_child_delete: logger.log: Child deleted - on_screen_unload_start: - logger.log: Screen unload start - on_screen_load_start: - logger.log: Screen load start - on_screen_load: - logger.log: Screen loaded - on_screen_unload: - logger.log: Screen unloaded on_size_change: logger.log: Size changed on_style_change: @@ -668,36 +707,6 @@ lvgl: logger.log: Layout changed on_get_self_size: logger.log: Get self size - on_invalidate_area: - logger.log: Invalidate area - on_resolution_change: - logger.log: Resolution changed - on_color_format_change: - logger.log: Color format changed - on_refr_request: - logger.log: Refresh request - on_refr_start: - logger.log: Refresh start - on_refr_ready: - logger.log: Refresh ready - on_render_start: - logger.log: Render start - on_render_ready: - logger.log: Render ready - on_flush_start: - logger.log: Flush start - on_flush_finish: - logger.log: Flush finish - on_flush_wait_start: - logger.log: Flush wait start - on_flush_wait_finish: - logger.log: Flush wait finish - on_update_layout_complete: - logger.log: Update layout complete - on_vsync: - logger.log: Vsync - on_vsync_request: - logger.log: Vsync request - led: id: lv_led color: 0x00FF00 diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index e6025e17fc..79ea06f16a 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -21,7 +21,7 @@ binary_sensor: ignore_strapping_warning: true display: - - platform: ili9xxx + - platform: mipi_spi spi_id: spi_bus model: st7789v id: second_display @@ -41,7 +41,7 @@ display: invert_colors: false update_interval: never - - platform: ili9xxx + - platform: mipi_spi spi_id: spi_bus model: st7789v id: tft_display diff --git a/tests/components/lvgl/test.esp32-p4-idf.yaml b/tests/components/lvgl/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..5fd9370255 --- /dev/null +++ b/tests/components/lvgl/test.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +display: + - platform: mipi_dsi + model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C +lvgl: + byte_order: little_endian + rotation: 90 + +psram: + +esp_ldo: + - channel: 3 + voltage: 2.5V diff --git a/tests/components/mcp23008/common.yaml b/tests/components/mcp23008/common.yaml index 4a407adfd8..7eeee409ff 100644 --- a/tests/components/mcp23008/common.yaml +++ b/tests/components/mcp23008/common.yaml @@ -1,6 +1,10 @@ mcp23008: - i2c_id: i2c_bus - id: mcp23008_hub + - i2c_id: i2c_bus + id: mcp23008_hub + - i2c_id: i2c_bus + id: mcp23008_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio @@ -9,6 +13,12 @@ binary_sensor: mcp23xxx: mcp23008_hub number: 0 mode: INPUT + - platform: gpio + id: mcp23008_binary_sensor_int + pin: + mcp23xxx: mcp23008_hub_int + number: 0 + mode: INPUT switch: - platform: gpio diff --git a/tests/components/mcp23008/test.esp32-idf.yaml b/tests/components/mcp23008/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/mcp23008/test.esp32-idf.yaml +++ b/tests/components/mcp23008/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23008/test.esp8266-ard.yaml b/tests/components/mcp23008/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/mcp23008/test.esp8266-ard.yaml +++ b/tests/components/mcp23008/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23008/test.rp2040-ard.yaml b/tests/components/mcp23008/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/mcp23008/test.rp2040-ard.yaml +++ b/tests/components/mcp23008/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/mcp23017/common.yaml b/tests/components/mcp23017/common.yaml index 54a97e911f..8b1e32600e 100644 --- a/tests/components/mcp23017/common.yaml +++ b/tests/components/mcp23017/common.yaml @@ -1,6 +1,10 @@ mcp23017: - i2c_id: i2c_bus - id: mcp23017_hub + - i2c_id: i2c_bus + id: mcp23017_hub + - i2c_id: i2c_bus + id: mcp23017_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio @@ -9,6 +13,12 @@ binary_sensor: mcp23xxx: mcp23017_hub number: 0 mode: INPUT + - platform: gpio + id: mcp23017_binary_sensor_int + pin: + mcp23xxx: mcp23017_hub_int + number: 0 + mode: INPUT switch: - platform: gpio diff --git a/tests/components/mcp23017/test.esp32-idf.yaml b/tests/components/mcp23017/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/mcp23017/test.esp32-idf.yaml +++ b/tests/components/mcp23017/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23017/test.esp8266-ard.yaml b/tests/components/mcp23017/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/mcp23017/test.esp8266-ard.yaml +++ b/tests/components/mcp23017/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23017/test.rp2040-ard.yaml b/tests/components/mcp23017/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/mcp23017/test.rp2040-ard.yaml +++ b/tests/components/mcp23017/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/mcp23s08/common.yaml b/tests/components/mcp23s08/common.yaml index 2170ae0459..327bf02ca9 100644 --- a/tests/components/mcp23s08/common.yaml +++ b/tests/components/mcp23s08/common.yaml @@ -2,3 +2,4 @@ mcp23s08: - id: mcp23s08_hub cs_pin: ${cs_pin} deviceaddress: 0 + interrupt_pin: ${interrupt_pin} diff --git a/tests/components/mcp23s08/test.esp32-idf.yaml b/tests/components/mcp23s08/test.esp32-idf.yaml index a3352cf880..eeb6941645 100644 --- a/tests/components/mcp23s08/test.esp32-idf.yaml +++ b/tests/components/mcp23s08/test.esp32-idf.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO15 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml diff --git a/tests/components/mcp23s08/test.esp8266-ard.yaml b/tests/components/mcp23s08/test.esp8266-ard.yaml index 595f31046a..ffc40b3595 100644 --- a/tests/components/mcp23s08/test.esp8266-ard.yaml +++ b/tests/components/mcp23s08/test.esp8266-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO15 + interrupt_pin: GPIO0 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml diff --git a/tests/components/mcp23s08/test.rp2040-ard.yaml b/tests/components/mcp23s08/test.rp2040-ard.yaml index 79ea6ce90b..09b87ca3f8 100644 --- a/tests/components/mcp23s08/test.rp2040-ard.yaml +++ b/tests/components/mcp23s08/test.rp2040-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO2 packages: spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml diff --git a/tests/components/mcp23s17/common.yaml b/tests/components/mcp23s17/common.yaml index a89beeb16b..150ecca325 100644 --- a/tests/components/mcp23s17/common.yaml +++ b/tests/components/mcp23s17/common.yaml @@ -2,3 +2,4 @@ mcp23s17: - id: mcp23s17_hub cs_pin: ${cs_pin} deviceaddress: 0 + interrupt_pin: ${interrupt_pin} diff --git a/tests/components/mcp23s17/test.esp32-idf.yaml b/tests/components/mcp23s17/test.esp32-idf.yaml index a3352cf880..eeb6941645 100644 --- a/tests/components/mcp23s17/test.esp32-idf.yaml +++ b/tests/components/mcp23s17/test.esp32-idf.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO15 packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml diff --git a/tests/components/mcp23s17/test.esp8266-ard.yaml b/tests/components/mcp23s17/test.esp8266-ard.yaml index 595f31046a..ffc40b3595 100644 --- a/tests/components/mcp23s17/test.esp8266-ard.yaml +++ b/tests/components/mcp23s17/test.esp8266-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO15 + interrupt_pin: GPIO0 packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml diff --git a/tests/components/mcp23s17/test.rp2040-ard.yaml b/tests/components/mcp23s17/test.rp2040-ard.yaml index 79ea6ce90b..09b87ca3f8 100644 --- a/tests/components/mcp23s17/test.rp2040-ard.yaml +++ b/tests/components/mcp23s17/test.rp2040-ard.yaml @@ -1,5 +1,6 @@ substitutions: cs_pin: GPIO5 + interrupt_pin: GPIO2 packages: spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml 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/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp new file mode 100644 index 0000000000..7846a31193 --- /dev/null +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -0,0 +1,396 @@ +#include "../common.h" + +namespace esphome::mitsubishi_cn105::testing { + +struct TestContext { + MockUARTComponent uart; + uart::UARTDevice device{&uart}; + TestableMitsubishiCN105 sut{device}; + + TestContext() { this->sut.set_current_time(0); } +}; + +TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { + auto ctx = TestContext{}; + + ctx.sut.set_current_time(123); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + + ctx.sut.initialize(); + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{123}); +} + +TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Connect response + ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55}); + + ctx.sut.set_current_time(200); + ASSERT_FALSE(ctx.sut.update()); + + // All bytes from UART should be consumed + EXPECT_TRUE(ctx.uart.rx.empty()); + // After successful connect we request status, first settings (0x02) + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{200}); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Clear TX bytes. + ctx.uart.tx.clear(); + + // Settings response + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99}); + + // Settings should still have initial values + EXPECT_FALSE(ctx.sut.status().power_on); + EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan()); + EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN); + EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN); + + ctx.sut.set_current_time(300); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.rx.empty()); + + // Check settings that we just read from received package + EXPECT_FALSE(ctx.sut.status().power_on); + EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f); + EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO); + EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO); + + // Now fetch room temperature (0x03) + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{300}); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Clear TX bytes. + ctx.uart.tx.clear(); + + // Room temperature response + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, + 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5}); + + // Room temperature should still have initial value + EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan()); + + ctx.sut.set_current_time(400); + EXPECT_FALSE(ctx.sut.is_status_initialized()); + ASSERT_TRUE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.rx.empty()); + EXPECT_TRUE(ctx.sut.is_status_initialized()); + + // Check room temperature we just read from received package + EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f); + + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{400}); +} + +TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // No response (no RX data), no retry yet + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + + // Still no response after 1999ms, no retry yet + ctx.sut.set_current_time(1999); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + + // Stop waiting after 2s and retry connect + ctx.sut.set_current_time(2000); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{2000}); +} + +TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // RX noise/unexpected traffic + ctx.uart.push_rx({0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46}); + + // Make sure we have enough bytes in buffer. + ASSERT_GT(ctx.uart.rx.size(), 64); + + // No valid response, no state change expected + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + + // Watchdog interrupts reading (max. 64 bytes at once) so we do not spend the whole loop draining UART + EXPECT_FALSE(ctx.uart.rx.empty()); + + // Next update will read remaining bytes, no state change expected + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_TRUE(ctx.uart.rx.empty()); +} + +TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // Mixed RX stream with partial, malformed, and oversized frames to test parser robustness + ctx.uart.push_rx({// ───────────────────────────── + // Noise (no 0xFC) -> should be ignored via preamble reset + // ──────────────────────────── + 0x01, 0x02, 0x03, 0x04, 0x05, + + // ───────────────────────────── + // Partial frame (declares payload len=5, but we cut it short) + // Later bytes will eventually force checksum mismatch and reset + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x05, 0xAA, 0xBB, + + // ───────────────────────────── + // Invalid header (header byte 3 should be 0x01, header byte 4 should be 0x30) + // Should reset quickly on header mismatch + // ───────────────────────────── + 0xFC, 0x62, 0xFF, 0xFF, 0x02, 0x01, 0x02, 0x00, + + // ───────────────────────────── + // Oversized length field (rejected by payload-too-large check at HEADER_LEN) + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0xFE, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, + 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + + // ───────────────────────────── + // Valid unknown-type frame (type=0x62), should be parsed successfully then ignored + // Frame: FC 62 01 30 02 AA BB 30 + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x02, 0xAA, 0xBB, 0x30, + + // ───────────────────────────── + // Invalid checksum (should be rejected at checksum check) + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x02, 0x10, 0x20, 0xFF, + + // ───────────────────────────── + // Back-to-back VALID frames (unknown type=0x62) to stress boundary handling. + // Frame A: FC 62 01 30 01 02 6C + // Frame B: FC 62 01 30 01 03 6B + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x01, 0x02, 0x6C, 0xFC, 0x62, 0x01, 0x30, 0x01, 0x03, 0x6B, + + // ───────────────────────────── + // Trailing noise + // ───────────────────────────── + 0x55, 0x66, 0x77, 0x88}); + + // Drain RX - no valid response, no state change expected + int iterations = 0; + while (!ctx.uart.rx.empty() && iterations++ < 10) { + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + } + + EXPECT_TRUE(ctx.uart.rx.empty()); +} + +TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { + auto ctx = TestContext{}; + + ctx.sut.set_update_interval(2000); + ctx.sut.set_current_time(80000); + + // No scheduled status update + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); + + // Status update completed, schedule next status update + ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; + ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional{80000}); + + // Wait for update_interval (ms) before doing another status update + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + + ctx.sut.set_current_time(81999); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + + ctx.sut.set_current_time(82000); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_FALSE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); + EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value()); +} + +TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { + auto ctx = TestContext{}; + + ctx.uart.push_rx( + {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); + + ctx.sut.update(); + + EXPECT_TRUE(ctx.sut.status().power_on); + EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_); + EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); + EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL); + EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET); +} + +TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { + auto ctx = TestContext{}; + + ctx.uart.push_rx( + {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); + + ctx.sut.update(); + + EXPECT_FALSE(ctx.sut.status().power_on); + EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_); + EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); + EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY); + EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4); +} + +TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { + auto ctx = TestContext{}; + + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().room_temperature, 16.0f); +} + +TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { + auto ctx = TestContext{}; + + ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); + + ctx.sut.update(); + + EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f); +} + +TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { + auto ctx = TestContext{}; + + ctx.sut.set_power(true); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B)); +} + +TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { + auto ctx = TestContext{}; + + ctx.sut.set_target_temperature(23.0f); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x71)); +} + +TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { + auto ctx = TestContext{}; + + ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.set_target_temperature(26.0f); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB4, 0x00, 0xC5)); +} + +TEST(MitsubishiCN105Tests, ApplyModeCool) { + auto ctx = TestContext{}; + + ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x02, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78)); +} + +TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { + auto ctx = TestContext{}; + + ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73)); +} + +TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { + auto ctx = TestContext{}; + + // Waiting for next scheduled status update + ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED; + ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + + // Nothing to do in update (rx empty, no timeout) + ASSERT_FALSE(ctx.sut.update()); + EXPECT_TRUE(ctx.uart.tx.empty()); + + // Write new values + ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.set_power(false); + ctx.sut.set_target_temperature(25.0f); + ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); + ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO); + + // Waiting for next status update must be interrupted and new values send to AC + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); + + // Write ACK response + ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + ASSERT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.cpp b/tests/components/mitsubishi_cn105/common.cpp new file mode 100644 index 0000000000..50993c5c2c --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.cpp @@ -0,0 +1,7 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105 { + +uint32_t get_loop_time_ms() { return testing::TestableMitsubishiCN105::test_loop_time_ms; } + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h new file mode 100644 index 0000000000..0862d64fa7 --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +}; + +class TestableMitsubishiCN105 : public MitsubishiCN105 { + public: + using MitsubishiCN105::MitsubishiCN105; + using MitsubishiCN105::State; + using MitsubishiCN105::state_; + using MitsubishiCN105::write_timeout_start_ms_; + using MitsubishiCN105::status_update_start_ms_; + using MitsubishiCN105::use_temperature_encoding_b_; + + void set_state(State s) { this->set_state_(s); } + void apply_settings() { this->apply_settings_(); } + + static inline uint32_t test_loop_time_ms = 0; + + void set_current_time(uint32_t ms) { test_loop_time_ms = ms; } +}; + +} // namespace esphome::mitsubishi_cn105::testing 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/components/pca9554/common.yaml b/tests/components/pca9554/common.yaml index 9e5e7f3342..82a88b90aa 100644 --- a/tests/components/pca9554/common.yaml +++ b/tests/components/pca9554/common.yaml @@ -3,6 +3,11 @@ pca9554: i2c_id: i2c_bus pin_count: 8 address: 0x3F + - id: pca9554_hub_int + i2c_id: i2c_bus + pin_count: 8 + address: 0x3E + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pca9554/test.esp32-idf.yaml b/tests/components/pca9554/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/pca9554/test.esp32-idf.yaml +++ b/tests/components/pca9554/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pca9554/test.esp8266-ard.yaml b/tests/components/pca9554/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/pca9554/test.esp8266-ard.yaml +++ b/tests/components/pca9554/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pca9554/test.rp2040-ard.yaml b/tests/components/pca9554/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/pca9554/test.rp2040-ard.yaml +++ b/tests/components/pca9554/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 09fa33164e..8a26b93015 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -3,6 +3,11 @@ pcf8574: i2c_id: i2c_bus address: 0x21 pcf8575: false + - id: pcf8574_hub_int + i2c_id: i2c_bus + address: 0x22 + pcf8575: false + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pcf8574/test.esp32-idf.yaml b/tests/components/pcf8574/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/pcf8574/test.esp32-idf.yaml +++ b/tests/components/pcf8574/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pcf8574/test.esp8266-ard.yaml b/tests/components/pcf8574/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/pcf8574/test.esp8266-ard.yaml +++ b/tests/components/pcf8574/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pcf8574/test.rp2040-ard.yaml b/tests/components/pcf8574/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/pcf8574/test.rp2040-ard.yaml +++ b/tests/components/pcf8574/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 2344622081..77a77fa3e4 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -1,7 +1,11 @@ pi4ioe5v6408: - i2c_id: i2c_bus - id: pi4ioe1 - address: 0x44 + - i2c_id: i2c_bus + id: pi4ioe1 + address: 0x44 + - i2c_id: i2c_bus + id: pi4ioe1_int + address: 0x45 + interrupt_pin: ${interrupt_pin} switch: - platform: gpio @@ -16,3 +20,8 @@ binary_sensor: pin: pi4ioe5v6408: pi4ioe1 number: 1 + - platform: gpio + id: sensor1_int + pin: + pi4ioe5v6408: pi4ioe1_int + number: 1 diff --git a/tests/components/pi4ioe5v6408/test.esp32-idf.yaml b/tests/components/pi4ioe5v6408/test.esp32-idf.yaml index 9a4779d822..a6eb3c1cb1 100644 --- a/tests/components/pi4ioe5v6408/test.esp32-idf.yaml +++ b/tests/components/pi4ioe5v6408/test.esp32-idf.yaml @@ -1,6 +1,7 @@ substitutions: i2c_sda: GPIO21 i2c_scl: GPIO22 + interrupt_pin: GPIO15 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml b/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml index 3429a2952a..cd6fef3042 100644 --- a/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml +++ b/tests/components/pi4ioe5v6408/test.rp2040-ard.yaml @@ -1,6 +1,7 @@ substitutions: i2c_sda: GPIO4 i2c_scl: GPIO5 + interrupt_pin: GPIO2 packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py deleted file mode 100644 index 0434b3e1b5..0000000000 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ /dev/null @@ -1,127 +0,0 @@ -import pytest - -from esphome.components import socket -from esphome.const import ( - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_BK72XX, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_LN882X, - PLATFORM_RTL87XX, -) -from esphome.core import CORE - - -def _setup_platform(platform=PLATFORM_ESP8266) -> None: - """Set up CORE.data with a platform for testing.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} - - -def test_require_wake_loop_threadsafe__first_call() -> None: - """Test that first call sets up define and consumes socket.""" - _setup_platform() - CORE.config = {"wifi": True} - socket.require_wake_loop_threadsafe() - - # Verify CORE.data was updated - assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True - - # Verify the define was added - assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) - - -def test_require_wake_loop_threadsafe__idempotent() -> None: - """Test that subsequent calls are idempotent.""" - # Set up initial state as if already called - CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True - CORE.config = {"ethernet": True} - - # Call again - should not raise or fail - socket.require_wake_loop_threadsafe() - - # Verify state is still True - assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True - - # Define should not be added since flag was already True - assert not any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) - - -def test_require_wake_loop_threadsafe__multiple_calls() -> None: - """Test that multiple calls only set up once.""" - _setup_platform() - # Call three times - CORE.config = {"openthread": True} - socket.require_wake_loop_threadsafe() - socket.require_wake_loop_threadsafe() - socket.require_wake_loop_threadsafe() - - # Verify CORE.data was set - assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True - - # Verify the define was added (only once, but we can just check it exists) - assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) - - -def test_require_wake_loop_threadsafe__no_networking() -> None: - """Test that wake loop is NOT configured when no networking is configured.""" - # Set up config without any networking components - CORE.config = {"esphome": {"name": "test"}, "logger": {}} - - # Call require_wake_loop_threadsafe - socket.require_wake_loop_threadsafe() - - # Verify CORE.data flag was NOT set (since has_networking returns False) - assert socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED not in CORE.data - - # Verify the define was NOT added - assert not any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) - - -def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -> None: - """Test that no socket is consumed when no networking is configured.""" - # Set up config without any networking components - CORE.config = {"logger": {}} - - # Track initial socket consumer state - initial_udp = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) - - # Call require_wake_loop_threadsafe - socket.require_wake_loop_threadsafe() - - # Verify no socket was consumed - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) - assert "socket.wake_loop_threadsafe" not in udp_consumers - assert udp_consumers == initial_udp - - -@pytest.mark.parametrize( - "platform", - [PLATFORM_ESP32, PLATFORM_BK72XX, PLATFORM_RTL87XX, PLATFORM_LN882X], -) -def test_require_wake_loop_threadsafe__fast_select_no_udp_socket( - platform: str, -) -> None: - """Test that fast select platforms use task notifications instead of UDP socket.""" - _setup_platform(platform) - CORE.config = {"wifi": True} - socket.require_wake_loop_threadsafe() - - # Verify the define was added - assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True - assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) - - # Verify no UDP socket was consumed (fast select platforms use FreeRTOS task notifications) - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) - assert "socket.wake_loop_threadsafe" not in udp_consumers - - -def test_require_wake_loop_threadsafe__non_fast_select_consumes_udp_socket() -> None: - """Test that platforms without fast select consume a UDP socket for wake notifications.""" - _setup_platform(PLATFORM_ESP8266) - CORE.config = {"wifi": True} - socket.require_wake_loop_threadsafe() - - # Verify UDP socket was consumed - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) - assert udp_consumers.get("socket.wake_loop_threadsafe") == 1 diff --git a/tests/components/time/common.yaml b/tests/components/time/common.yaml index 465be045db..cd258c7aa6 100644 --- a/tests/components/time/common.yaml +++ b/tests/components/time/common.yaml @@ -6,5 +6,9 @@ api: time: - platform: homeassistant + on_time: + - seconds: "0,10,20,30,40,50" + then: + - logger.log: "CronTrigger fired (every 10 seconds)" - platform: sntp id: sntp_time diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b652b4174c..7c85bf753c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -198,6 +198,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s ' - "-g" # Add debug symbols', ) + # Replace external component path placeholder if present + if "EXTERNAL_COMPONENT_PATH" in content: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) + return content diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py new file mode 100644 index 0000000000..ba9eff2d89 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py @@ -0,0 +1,4 @@ +"""Legacy climate component — tests deprecated ClimateTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py new file mode 100644 index 0000000000..0810ae02a1 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py @@ -0,0 +1,16 @@ +"""Legacy climate platform that uses deprecated ClimateTraits setters.""" + +import esphome.codegen as cg +from esphome.components import climate +import esphome.config_validation as cv +from esphome.types import ConfigType + +legacy_climate_ns = cg.esphome_ns.namespace("legacy_climate_test") +LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Component) + +CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h new file mode 100644 index 0000000000..bdf5179fa5 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/components/climate/climate.h" +#include "esphome/core/component.h" + +namespace esphome::legacy_climate_test { + +/// Test climate that uses the DEPRECATED ClimateTraits setters for custom modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyClimate : public climate::Climate, public Component { + public: + void setup() override { + this->mode = climate::CLIMATE_MODE_OFF; + this->target_temperature = 22.0f; + this->current_temperature = 20.0f; + this->publish_state(); + } + + protected: + climate::ClimateTraits traits() override { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_COOL}); + traits.set_visual_min_temperature(16.0f); + traits.set_visual_max_temperature(30.0f); + traits.set_visual_temperature_step(0.5f); + + // DEPRECATED API: setting custom modes directly on ClimateTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_custom_fan_modes({"Turbo", "Silent", "Auto"}); + traits.set_supported_custom_presets({"Eco Mode", "Night Mode"}); +#pragma GCC diagnostic pop + + return traits; + } + + void control(const climate::ClimateCall &call) override { + if (call.get_mode().has_value()) { + this->mode = *call.get_mode(); + } + if (call.get_target_temperature().has_value()) { + this->target_temperature = *call.get_target_temperature(); + } + if (call.has_custom_fan_mode()) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } + if (call.has_custom_preset()) { + this->set_custom_preset_(call.get_custom_preset()); + } + this->publish_state(); + } +}; + +} // namespace esphome::legacy_climate_test diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py new file mode 100644 index 0000000000..714be181fe --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py @@ -0,0 +1,4 @@ +"""Legacy fan component — tests deprecated FanTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py new file mode 100644 index 0000000000..985d97d081 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -0,0 +1,16 @@ +"""Legacy fan platform that uses deprecated FanTraits setters.""" + +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") +LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = fan.fan_schema(LegacyFan).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h new file mode 100644 index 0000000000..ac378b59c5 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -0,0 +1,45 @@ +#pragma once + +#include "esphome/components/fan/fan.h" +#include "esphome/core/component.h" + +namespace esphome::legacy_fan_test { + +/// Test fan that uses the DEPRECATED FanTraits setters for preset modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyFan : public fan::Fan, public Component { + public: + void setup() override { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(*this); + } + this->publish_state(); + } + + fan::FanTraits get_traits() override { + auto traits = fan::FanTraits(false, true, false, 3); + + // DEPRECATED API: setting preset modes directly on FanTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_preset_modes({"Turbo", "Silent", "Eco"}); +#pragma GCC diagnostic pop + + return traits; + } + + protected: + void control(const fan::FanCall &call) override { + if (call.get_state().has_value()) { + this->state = *call.get_state(); + } + if (call.get_speed().has_value()) { + this->speed = *call.get_speed(); + } + this->apply_preset_mode_(call); + this->publish_state(); + } +}; + +} // namespace esphome::legacy_fan_test diff --git a/tests/integration/fixtures/legacy_climate_compat.yaml b/tests/integration/fixtures/legacy_climate_compat.yaml new file mode 100644 index 0000000000..112e50a468 --- /dev/null +++ b/tests/integration/fixtures/legacy_climate_compat.yaml @@ -0,0 +1,18 @@ +esphome: + name: legacy-climate-compat + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_climate_component] + +climate: + - platform: legacy_climate_component + name: "Legacy Climate" + id: legacy_climate diff --git a/tests/integration/fixtures/legacy_fan_compat.yaml b/tests/integration/fixtures/legacy_fan_compat.yaml new file mode 100644 index 0000000000..256fd4e4c1 --- /dev/null +++ b/tests/integration/fixtures/legacy_fan_compat.yaml @@ -0,0 +1,18 @@ +esphome: + name: legacy-fan-compat + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_fan_component] + +fan: + - platform: legacy_fan_component + name: "Legacy Fan" + id: legacy_fan diff --git a/tests/integration/fixtures/light_control_action.yaml b/tests/integration/fixtures/light_control_action.yaml new file mode 100644 index 0000000000..66f0cf1873 --- /dev/null +++ b/tests/integration/fixtures/light_control_action.yaml @@ -0,0 +1,139 @@ +esphome: + name: light-control-action-test +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +globals: + - id: test_brightness + type: float + initial_value: "0.75" + +output: + - platform: template + id: test_red + type: float + write_action: + - lambda: "" + - platform: template + id: test_green + type: float + write_action: + - lambda: "" + - platform: template + id: test_blue + type: float + write_action: + - lambda: "" + - platform: template + id: test_cold_white + type: float + write_action: + - lambda: "" + - platform: template + id: test_warm_white + type: float + write_action: + - lambda: "" + +light: + - platform: rgbww + name: "Test Light" + id: test_light + red: test_red + green: test_green + blue: test_blue + cold_white: test_cold_white + warm_white: test_warm_white + cold_white_color_temperature: 6536 K + warm_white_color_temperature: 2000 K + effects: + - random: + name: "Test Effect" + transition_length: 10ms + update_interval: 10ms + +button: + # Test 1: light.turn_on with RGB constants + - platform: template + id: btn_turn_on_rgb + name: "Turn On RGB" + on_press: + - light.turn_on: + id: test_light + brightness: 1.0 + red: 0.0 + green: 0.0 + blue: 1.0 + + # Test 2: light.turn_off + - platform: template + id: btn_turn_off + name: "Turn Off" + on_press: + - light.turn_off: + id: test_light + + # Test 3: light.turn_on with color_temperature + - platform: template + id: btn_turn_on_ct + name: "Turn On CT" + on_press: + - light.turn_on: + id: test_light + color_temperature: 4000 K + brightness: 0.8 + + # Test 4: light.turn_on with effect + - platform: template + id: btn_turn_on_effect + name: "Turn On Effect" + on_press: + - light.turn_on: + id: test_light + effect: "Test Effect" + + # Test 5: light.turn_on with effect none to clear it + - platform: template + id: btn_clear_effect + name: "Clear Effect" + on_press: + - light.turn_on: + id: test_light + effect: "None" + + # Test 6: light.control with cold/warm white + - platform: template + id: btn_control_cw + name: "Control CW" + on_press: + - light.control: + id: test_light + cold_white: 0.9 + warm_white: 0.1 + + # Test 7: light.turn_on with lambda brightness (tests lambda path) + - platform: template + id: btn_lambda_brightness + name: "Lambda Brightness" + on_press: + - light.turn_on: + id: test_light + brightness: !lambda "return id(test_brightness);" + red: 1.0 + green: 0.0 + blue: 0.0 + + # Test 8: light.turn_on with transition_length + - platform: template + id: btn_turn_on_transition + name: "Turn On Transition" + on_press: + - light.turn_on: + id: test_light + brightness: 0.5 + transition_length: 0s + red: 0.5 + green: 0.5 + blue: 0.0 diff --git a/tests/integration/fixtures/scheduler_interval_reschedule.yaml b/tests/integration/fixtures/scheduler_interval_reschedule.yaml new file mode 100644 index 0000000000..a0f882deee --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_reschedule.yaml @@ -0,0 +1,113 @@ +esphome: + name: sched-interval-resched + +host: +api: +logger: + level: DEBUG + +globals: + # Counters for each interval + - id: fast_count + type: int + initial_value: "0" + - id: medium_count + type: int + initial_value: "0" + - id: slow_count + type: int + initial_value: "0" + # Track interval that cancels itself + - id: self_cancel_count + type: int + initial_value: "0" + # Track interval that schedules a timeout from its callback + - id: callback_timeout_fired + type: bool + initial_value: "false" + # Track set_interval replacing itself from within callback + - id: replace_count + type: int + initial_value: "0" + - id: replaced_count + type: int + initial_value: "0" + +interval: + # Fast interval: 50ms + - interval: 50ms + then: + - lambda: |- + id(fast_count) += 1; + if (id(fast_count) == 10) { + ESP_LOGI("test", "FAST_10_REACHED"); + } + + # Medium interval: 100ms + - interval: 100ms + then: + - lambda: |- + id(medium_count) += 1; + if (id(medium_count) == 5) { + ESP_LOGI("test", "MEDIUM_5_REACHED fast_count=%d", id(fast_count)); + } + + # Slow interval: 200ms + - interval: 200ms + then: + - lambda: |- + id(slow_count) += 1; + if (id(slow_count) == 3) { + ESP_LOGI("test", "SLOW_3_REACHED fast_count=%d medium_count=%d", id(fast_count), id(medium_count)); + } + + # Interval that cancels itself after 3 fires + - interval: 75ms + id: self_cancelling + then: + - lambda: |- + id(self_cancel_count) += 1; + ESP_LOGI("test", "SELF_CANCEL_FIRE count=%d", id(self_cancel_count)); + if (id(self_cancel_count) >= 3) { + id(self_cancelling)->stop_poller(); + ESP_LOGI("test", "SELF_CANCEL_STOPPED"); + } + + # Interval that schedules a timeout from its callback (tests to_add_ path) + - interval: 150ms + id: timeout_creator + then: + - lambda: |- + if (!id(callback_timeout_fired)) { + // Schedule a one-shot timeout from within an interval callback + // This goes through to_add_ (not the direct push_heap path) + id(timeout_creator)->set_timeout("test_timeout", 10, []() { + id(callback_timeout_fired) = true; + ESP_LOGI("test", "CALLBACK_TIMEOUT_FIRED"); + }); + // Stop this interval after scheduling the timeout + id(timeout_creator)->stop_poller(); + } + + # Interval that calls set_interval with the same name from within its callback, + # replacing itself. Tests that the old item (marked removed) is not rescheduled + # via push_heap, and the new item goes through to_add_ correctly. + - interval: 60ms + id: replace_test + then: + - lambda: |- + id(replace_count) += 1; + if (id(replace_count) == 1) { + ESP_LOGI("test", "REPLACE_ORIGINAL_FIRE"); + // Replace the polling interval with a named interval on the same component + id(replace_test)->set_interval("replaced_interval", 80, []() { + id(replaced_count) += 1; + ESP_LOGI("test", "REPLACED_FIRE count=%d", id(replaced_count)); + if (id(replaced_count) >= 3) { + id(replace_test)->cancel_interval("replaced_interval"); + ESP_LOGI("test", "REPLACED_STOPPED"); + } + }); + // Stop the original polling interval + id(replace_test)->stop_poller(); + } diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index 3ff7ab01bd..da36da4de1 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -22,6 +22,8 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: responses: @@ -46,7 +48,7 @@ modbus: modbus_controller: - address: 1 id: modbus_controller_ok - max_cmd_retries: 0 + max_cmd_retries: 2 update_interval: 1s - address: 2 id: modbus_controller_slow @@ -89,4 +91,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index e3e8c8c8da..9bc4dc50e9 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -22,6 +22,8 @@ uart: uart_mock: - id: virtual_uart_dev baud_rate: 9600 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: on_tx: @@ -40,7 +42,8 @@ uart_mock: 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; - uart_mock.inject_rx: # Second USB packet: rest of response (staged with 40ms latency) delay: 40ms - data: !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + data: + !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x42,0x6F,0xCC,0xCD,0x43,0x7C,0xB8,0x10,0x3D,0x38,0x51,0xEC, 0x43,0x81,0x1B,0xE7,0x3B,0x03,0x12,0x6F,0x50,0x1B}; @@ -61,4 +64,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml new file mode 100644 index 0000000000..b657a6fd21 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server.yaml @@ -0,0 +1,124 @@ +esphome: + name: uart-mock-modbus-server-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) + - delay: 100ms + # Read holding register 7 on device 2 + # Reply from device 2 + # Read holding register 5 on device 1 (read_after_peer_response) + inject_rx: + [ + 0x02, + 0x03, + 0x00, + 0x07, + 0x00, + 0x01, + 0x35, + 0xF8, + 0x02, + 0x03, + 0x02, + 0x00, + 0xF0, + 0xFC, + 0x00, + 0x01, + 0x03, + 0x00, + 0x05, + 0x00, + 0x01, + 0x94, + 0x0B, + ] + - delay: 100ms + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response + - delay: 100ms + # Read holding register 7 on device 2, with no response + # Read holding register A on device 1 (read_after_peer_timeout) + inject_rx: + [ + 0x02, + 0x03, + 0x00, + 0x07, + 0x00, + 0x01, + 0x35, + 0xF8, + 0x01, + 0x03, + 0x00, + 0x0A, + 0x00, + 0x01, + 0xA4, + 0x08, + ] + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_controller: + - address: 1 + server_registers: + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(basic_read).publish_state(1); + return 1; + - address: 0x05 + value_type: U_WORD + read_lambda: |- + id(read_after_peer_response).publish_state(1); + return 1; + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(read_after_peer_timeout).publish_state(1); + return 1; + +sensor: + - platform: template + name: "basic_read" + id: basic_read + - platform: template + name: "read_after_peer_response" + id: read_after_peer_response + - platform: template + name: "read_after_peer_timeout" + id: read_after_peer_timeout + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml new file mode 100644 index 0000000000..f0f2c56a36 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -0,0 +1,180 @@ +esphome: + name: uart-mock-modbus-server-contro + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 99; + - address: 0x03 + value_type: S_WORD + read_lambda: return -99; + - address: 0x05 + value_type: U_DWORD + read_lambda: return 16909060; + - address: 0x08 + value_type: S_DWORD + read_lambda: return -16909060; + - address: 0x0B + value_type: U_DWORD_R + read_lambda: return 67305985; + - address: 0x0E + value_type: S_DWORD_R + read_lambda: return -67305985; + - address: 0x11 + value_type: U_QWORD + read_lambda: return 72623859790382856; + - address: 0x16 + value_type: S_QWORD + read_lambda: return -72623859790382856; + - address: 0x1B + value_type: U_QWORD_R + read_lambda: return 578437695752307201; + - address: 0x20 + value_type: S_QWORD_R + read_lambda: return -578437695752307201; + - address: 0x25 + value_type: FP32 + read_lambda: return 3.14; + - address: 0x28 + value_type: FP32_R + read_lambda: return 3.14; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml new file mode 100644 index 0000000000..7ec67b03db --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -0,0 +1,118 @@ +esphome: + name: uart-mock-modbus-server-mult + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + update_interval: 1s + id: modbus_controller_1 + - address: 2 + modbus_id: virtual_modbus_client + update_interval: 1s + id: modbus_controller_2 + + - address: 1 + modbus_id: virtual_modbus_server + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 2 + modbus_id: virtual_modbus_server_2 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 929; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: "reg_u_word_2" + address: 0x01 + register_type: holding + value_type: U_WORD + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_server_2).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml new file mode 100644 index 0000000000..3edcc73f07 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -0,0 +1,330 @@ +esphome: + name: uart-mock-modbus-srv-write + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_u_word + type: uint16_t + initial_value: "11" + - id: stored_s_word + type: int16_t + initial_value: "-11" + - id: stored_u_dword + type: uint32_t + initial_value: "1001" + - id: stored_s_dword + type: int32_t + initial_value: "-1001" + - id: stored_u_dword_r + type: uint32_t + initial_value: "3003" + - id: stored_s_dword_r + type: int32_t + initial_value: "-3003" + - id: stored_u_qword + type: uint64_t + initial_value: "5005" + - id: stored_s_qword + type: int64_t + initial_value: "-5005" + - id: stored_u_qword_r + type: uint64_t + initial_value: "7007" + - id: stored_s_qword_r + type: int64_t + initial_value: "-7007" + - id: stored_fp32 + type: float + initial_value: "1.5" + - id: stored_fp32_r + type: float + initial_value: "2.5" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 2s + id: modbus_controller_1 + + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; + - address: 0x03 + value_type: S_WORD + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; + - address: 0x05 + value_type: U_DWORD + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; + - address: 0x08 + value_type: S_DWORD + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; + - address: 0x0B + value_type: U_DWORD_R + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; + - address: 0x0E + value_type: S_DWORD_R + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; + - address: 0x11 + value_type: U_QWORD + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; + - address: 0x16 + value_type: S_QWORD + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; + - address: 0x1B + value_type: U_QWORD_R + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; + - address: 0x20 + value_type: S_QWORD_R + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; + - address: 0x25 + value_type: FP32 + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; + - address: 0x28 + value_type: FP32_R + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index f4cf0bde37..c670864085 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -22,6 +22,8 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: on_tx: @@ -61,4 +63,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 5792a8e804..d42b50ecdb 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -346,3 +346,109 @@ class SensorStateCollector: else: self._waiters.append((condition, future)) return future + + +class SensorTracker: + """Data-driven sensor state tracker with expected-value futures. + + Tracks sensor state updates and resolves futures when sensors report + specific expected values. Eliminates per-sensor future boilerplate. + + Usage:: + + tracker = SensorTracker(["reg_u_word", "reg_s_word"]) + futures = tracker.expect_all({"reg_u_word": 99, "reg_s_word": -99}) + # ... subscribe_states with tracker.on_state, start scenario ... + await tracker.await_all(futures) + """ + + def __init__(self, sensor_names: list[str]) -> None: + self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names} + self.key_to_sensor: dict[int, str] = {} + self._expectations: dict[str, list[tuple[object, asyncio.Future]]] = {} + + _ANY = object() # Sentinel: match any value + + def expect(self, name: str, value: object) -> asyncio.Future: + """Register an expected value for *name* and return a future for it.""" + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._expectations.setdefault(name, []).append((value, future)) + return future + + def expect_any(self, name: str) -> asyncio.Future: + """Register a future that resolves on *any* state update for *name*.""" + return self.expect(name, self._ANY) + + def expect_all(self, expected: dict[str, object]) -> dict[str, asyncio.Future]: + """Call ``expect`` for every entry and return a dict of futures.""" + return {name: self.expect(name, value) for name, value in expected.items()} + + def on_state(self, state: EntityState) -> None: + """State callback suitable for ``subscribe_states``.""" + if not isinstance(state, SensorState) or state.missing_state: + return + sensor_name = self.key_to_sensor.get(state.key) + if not sensor_name or sensor_name not in self.sensor_states: + return + self.sensor_states[sensor_name].append(state.state) + for expected_value, future in self._expectations.get(sensor_name, []): + if not future.done() and ( + expected_value is self._ANY or state.state == expected_value + ): + future.set_result(True) + break + + async def await_change( + self, future: asyncio.Future, name: str, timeout: float = 2.0 + ) -> None: + """Wait for a sensor future to resolve; fail the test on timeout.""" + try: + await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + import pytest + + pytest.fail( + f"Timeout waiting for {name} change. Received sensor states:\n" + f" {name}: {self.sensor_states[name]}\n" + ) + + async def await_must_not_change( + self, future: asyncio.Future, name: str, timeout: float = 2.0 + ) -> None: + """Assert a sensor future does NOT resolve within the timeout.""" + try: + await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + return # Expected + import pytest + + pytest.fail( + f"{name} change should not have been triggered, but was. " + f"Received sensor states:\n {name}: {self.sensor_states[name]}\n" + ) + + async def await_all( + self, futures: dict[str, asyncio.Future], timeout: float = 2.0 + ) -> None: + """Await every future in *futures*, failing with per-sensor diagnostics.""" + for name, future in futures.items(): + await self.await_change(future, name, timeout=timeout) + + async def setup_and_start_scenario(self, client) -> list: + """Wire up subscriptions, wait for initial states, press Start Scenario.""" + entities, _ = await client.list_entities_services() + self.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) + ) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + import pytest + + pytest.fail("Timeout waiting for initial states") + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + return entities diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py new file mode 100644 index 0000000000..aad71dd04a --- /dev/null +++ b/tests/integration/test_legacy_climate_compat.py @@ -0,0 +1,96 @@ +"""Integration test for backward compatibility of deprecated ClimateTraits setters. + +Verifies that external components using the old traits.set_supported_custom_fan_modes() +and traits.set_supported_custom_presets() API still work correctly during the +deprecation period. + +Remove this entire test file and the legacy_climate_component external component +in 2026.11.0 when the deprecated ClimateTraits setters are removed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import aioesphomeapi +from aioesphomeapi import ClimateInfo +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_climate_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated ClimateTraits custom mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, ( + f"Expected 1 climate entity, got {len(climate_infos)}" + ) + + test_climate = climate_infos[0] + + # Verify custom fan modes set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_fan_modes) == { + "Turbo", + "Silent", + "Auto", + }, ( + f"Expected custom fan modes {{Turbo, Silent, Auto}}, " + f"got {test_climate.supported_custom_fan_modes}" + ) + + # Verify custom presets set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_presets) == { + "Eco Mode", + "Night Mode", + }, ( + f"Expected custom presets {{Eco Mode, Night Mode}}, " + f"got {test_climate.supported_custom_presets}" + ) + + # Set up state tracking with InitialStateHelper + turbo_future: asyncio.Future[aioesphomeapi.ClimateState] = loop.create_future() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, aioesphomeapi.ClimateState) + and state.custom_fan_mode == "Turbo" + and not turbo_future.done() + ): + turbo_future.set_result(state) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify we can set a custom fan mode via API (tests find_custom_fan_mode_ compat path) + client.climate_command(test_climate.key, custom_fan_mode="Turbo") + + try: + turbo_state = await asyncio.wait_for(turbo_future, timeout=5.0) + except TimeoutError: + pytest.fail("Custom fan mode 'Turbo' not received within 5 seconds") + + assert turbo_state.custom_fan_mode == "Turbo" diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py new file mode 100644 index 0000000000..5ee41772f3 --- /dev/null +++ b/tests/integration/test_legacy_fan_compat.py @@ -0,0 +1,82 @@ +"""Integration test for backward compatibility of deprecated FanTraits setters. + +Verifies that external components using the old traits.set_supported_preset_modes() +API still work correctly during the deprecation period. + +Remove this entire test file and the legacy_fan_component external component +in 2026.11.0 when the deprecated FanTraits setters are removed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_fan_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated FanTraits preset mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + + fan_infos = [e for e in entities if isinstance(e, FanInfo)] + assert len(fan_infos) == 1, f"Expected 1 fan entity, got {len(fan_infos)}" + + test_fan = fan_infos[0] + + # Verify preset modes set via deprecated FanTraits setter are exposed + assert set(test_fan.supported_preset_modes) == { + "Turbo", + "Silent", + "Eco", + }, ( + f"Expected preset modes {{Turbo, Silent, Eco}}, " + f"got {test_fan.supported_preset_modes}" + ) + + # Verify speed support + assert test_fan.supports_speed is True + assert test_fan.supported_speed_count == 3 + + # Subscribe and wait for initial states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Wait for initial state + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + # Turn on fan with preset mode (tests find_preset_mode_ compat path) + state_event.clear() + client.fan_command( + key=test_fan.key, + state=True, + preset_mode="Turbo", + ) + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + fan_state = states[test_fan.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Turbo" diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py new file mode 100644 index 0000000000..9a5c16a04d --- /dev/null +++ b/tests/integration/test_light_control_action.py @@ -0,0 +1,95 @@ +"""Integration test for LightControlAction. + +Tests that light.turn_on, light.turn_off, and light.control automation actions +work correctly with the compact per-field union storage. Exercises both constant +value and lambda paths. +""" + +import asyncio +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LightControlAction with constants and lambdas.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + # Get entities + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + """Wait for a state change for the given entity key.""" + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + """Press a button and wait for light state change.""" + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(light.key) + + # Test 1: light.turn_on with RGB constants + state = await press_and_wait("Turn On RGB") + assert state.state is True + assert state.brightness == pytest.approx(1.0) + assert state.red == pytest.approx(0.0, abs=0.01) + assert state.green == pytest.approx(0.0, abs=0.01) + assert state.blue == pytest.approx(1.0, abs=0.01) + + # Test 2: light.turn_off + state = await press_and_wait("Turn Off") + assert state.state is False + + # Test 3: light.turn_on with color_temperature + state = await press_and_wait("Turn On CT") + assert state.state is True + assert state.brightness == pytest.approx(0.8) + assert state.color_temperature == pytest.approx(250.0) # 4000K = 250 mireds + + # Test 4: light.turn_on with effect + state = await press_and_wait("Turn On Effect") + assert state.effect == "Test Effect" + + # Test 5: Clear effect + state = await press_and_wait("Clear Effect") + assert state.effect == "None" + + # Test 6: light.control with cold/warm white + state = await press_and_wait("Control CW") + assert state.cold_white == pytest.approx(0.9, abs=0.1) + assert state.warm_white == pytest.approx(0.1, abs=0.1) + + # Test 7: light.turn_on with lambda brightness + # The global test_brightness is 0.75 + state = await press_and_wait("Lambda Brightness") + assert state.state is True + assert state.brightness == pytest.approx(0.75, abs=0.05) + assert state.red == pytest.approx(1.0, abs=0.01) + assert state.green == pytest.approx(0.0, abs=0.01) + assert state.blue == pytest.approx(0.0, abs=0.01) + + # Test 8: light.turn_on with transition_length and brightness + state = await press_and_wait("Turn On Transition") + assert state.state is True + assert state.brightness == pytest.approx(0.5) diff --git a/tests/integration/test_scheduler_interval_reschedule.py b/tests/integration/test_scheduler_interval_reschedule.py new file mode 100644 index 0000000000..d141bd9f15 --- /dev/null +++ b/tests/integration/test_scheduler_interval_reschedule.py @@ -0,0 +1,165 @@ +"""Test that intervals are correctly rescheduled after firing. + +This test verifies the optimization where fired intervals are pushed directly +back into the scheduler's heap (items_) via push_back() + push_heap(), instead +of routing through the to_add_ staging vector and process_to_add_slow_path_(). + +Key scenarios tested: +1. Multiple intervals at different periods all fire at correct rates +2. Heap ordering is preserved — faster intervals fire proportionally more often +3. An interval that cancels itself mid-callback is not rescheduled +4. A timeout scheduled from within an interval callback (to_add_ path) still works +5. An interval that replaces itself via set_interval from within its callback +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_reschedule( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that intervals are correctly rescheduled via direct heap insertion.""" + loop = asyncio.get_running_loop() + + # Futures for each milestone + fast_10_future: asyncio.Future[None] = loop.create_future() + medium_5_future: asyncio.Future[tuple[int]] = loop.create_future() + slow_3_future: asyncio.Future[tuple[int, int]] = loop.create_future() + self_cancel_stopped_future: asyncio.Future[None] = loop.create_future() + callback_timeout_future: asyncio.Future[None] = loop.create_future() + replace_original_future: asyncio.Future[None] = loop.create_future() + replaced_stopped_future: asyncio.Future[None] = loop.create_future() + + self_cancel_fire_count = 0 + replaced_fire_count = 0 + + def on_log_line(line: str) -> None: + nonlocal self_cancel_fire_count, replaced_fire_count + + if "FAST_10_REACHED" in line and not fast_10_future.done(): + fast_10_future.set_result(None) + + match = re.search(r"MEDIUM_5_REACHED fast_count=(\d+)", line) + if match and not medium_5_future.done(): + medium_5_future.set_result((int(match.group(1)),)) + + match = re.search(r"SLOW_3_REACHED fast_count=(\d+) medium_count=(\d+)", line) + if match and not slow_3_future.done(): + slow_3_future.set_result((int(match.group(1)), int(match.group(2)))) + + match = re.search(r"SELF_CANCEL_FIRE count=(\d+)", line) + if match: + self_cancel_fire_count = int(match.group(1)) + + if "SELF_CANCEL_STOPPED" in line and not self_cancel_stopped_future.done(): + self_cancel_stopped_future.set_result(None) + + if "CALLBACK_TIMEOUT_FIRED" in line and not callback_timeout_future.done(): + callback_timeout_future.set_result(None) + + if "REPLACE_ORIGINAL_FIRE" in line and not replace_original_future.done(): + replace_original_future.set_result(None) + + match = re.search(r"REPLACED_FIRE count=(\d+)", line) + if match: + replaced_fire_count = int(match.group(1)) + + if "REPLACED_STOPPED" in line and not replaced_stopped_future.done(): + replaced_stopped_future.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-resched" + + # 1. Fast interval (50ms) should reach 10 fires within ~600ms + try: + await asyncio.wait_for(fast_10_future, timeout=5.0) + except TimeoutError: + pytest.fail("Fast interval (50ms) did not fire 10 times") + + # 2. Medium interval (100ms) should reach 5 fires + # At that point, fast_count should be roughly 2x medium_count + try: + result = await asyncio.wait_for(medium_5_future, timeout=5.0) + except TimeoutError: + pytest.fail("Medium interval (100ms) did not fire 5 times") + + fast_at_medium_5 = result[0] + # Fast runs at 50ms, medium at 100ms, so fast should be ~2x medium + # Allow some slack for scheduling jitter + assert fast_at_medium_5 >= 7, ( + f"Fast interval should have fired at least 7 times when medium hit 5, " + f"but only fired {fast_at_medium_5} times" + ) + + # 3. Slow interval (200ms) should reach 3 fires + # At that point, both fast and medium should have proportionally more fires + try: + result = await asyncio.wait_for(slow_3_future, timeout=5.0) + except TimeoutError: + pytest.fail("Slow interval (200ms) did not fire 3 times") + + fast_at_slow_3, medium_at_slow_3 = result + # At 600ms: fast ~12, medium ~6, slow 3 + assert fast_at_slow_3 >= 8, ( + f"Fast should have fired at least 8 times when slow hit 3, " + f"but only fired {fast_at_slow_3}" + ) + assert medium_at_slow_3 >= 4, ( + f"Medium should have fired at least 4 times when slow hit 3, " + f"but only fired {medium_at_slow_3}" + ) + + # 4. Self-cancelling interval should have stopped after exactly 3 fires + try: + await asyncio.wait_for(self_cancel_stopped_future, timeout=5.0) + except TimeoutError: + pytest.fail("Self-cancelling interval did not stop") + + # Wait a bit to ensure it doesn't fire again + await asyncio.sleep(0.3) + assert self_cancel_fire_count == 3, ( + f"Self-cancelling interval fired {self_cancel_fire_count} times, " + f"expected exactly 3" + ) + + # 5. Timeout scheduled from interval callback should have fired + try: + await asyncio.wait_for(callback_timeout_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout scheduled from interval callback did not fire") + + # 6. Interval that replaces itself via set_interval from within callback + # The original fires once, sets up a new named interval, then stops itself. + # The replacement interval should fire 3 times then cancel itself. + try: + await asyncio.wait_for(replace_original_future, timeout=5.0) + except TimeoutError: + pytest.fail("Replace-test original interval did not fire") + + try: + await asyncio.wait_for(replaced_stopped_future, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Replaced interval did not stop. Fired {replaced_fire_count} times" + ) + + # Wait to ensure replacement doesn't fire again after cancellation + await asyncio.sleep(0.3) + assert replaced_fire_count == 3, ( + f"Replaced interval fired {replaced_fire_count} times, expected exactly 3" + ) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e341d86f53..e8dfa1b822 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -14,15 +14,67 @@ test_uart_mock_modbus_no_threshold : from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import Callable +from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, EntityState, SensorState +from aioesphomeapi import NumberInfo import pytest -from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .state_utils import SensorTracker, find_entity from .types import APIClientConnectedFactory, RunCompiledFunction +@dataclass +class RegisterTestCase: + """Test parameters for a single modbus register write/read round-trip.""" + + initial_value: object + write_number_name: str + write_value: float + post_write_value: object + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_modbus_line_callback() -> tuple[Callable[[str], None], list[str], list[str]]: + """Return a (callback, error_lines, warning_lines) tuple for tracking modbus log output. + + Only captures bus-level modbus messages ([modbus:]), not modbus_controller + scheduling noise (e.g. "Duplicate modbus command found"). + """ + error_log_lines: list[str] = [] + warning_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "[E][modbus:" in line: + error_log_lines.append(line) + if "[W][modbus:" in line: + warning_log_lines.append(line) + + return line_callback, error_log_lines, warning_log_lines + + +def _assert_no_modbus_errors( + error_log_lines: list[str], warning_log_lines: list[str] +) -> None: + assert len(error_log_lines) == 0, ( + "Expect no errors logged by the modbus mock, but got:\n" + + "\n".join(error_log_lines) + ) + assert len(warning_log_lines) == 0, ( + "Expect no warnings logged by the modbus mock, but got:\n" + + "\n".join(warning_log_lines) + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_uart_mock_modbus( yaml_config: str, @@ -30,127 +82,41 @@ async def test_uart_mock_modbus( api_client_connected: APIClientConnectedFactory, ) -> None: """Test basic modbus data parsing.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" + + tracker = SensorTracker( + [ + "basic_register", + "delayed_response", + "late_response", + "no_response", + "exception_response", + ] ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "basic_register": [], - "delayed_response": [], - "late_response": [], - "no_response": [], - "exception_response": [], - } - - basic_register_changed = loop.create_future() - delayed_response_changed = loop.create_future() - late_response_changed = loop.create_future() - no_response_changed = loop.create_future() - exception_response_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - if ( - sensor_name == "basic_register" - and state.state == 259.0 - and not basic_register_changed.done() - ): - basic_register_changed.set_result(True) - elif ( - sensor_name == "delayed_response" - and state.state == 255.0 - and not delayed_response_changed.done() - ): - delayed_response_changed.set_result(True) - elif ( - sensor_name == "late_response" and not late_response_changed.done() - ): - late_response_changed.set_result(True) - elif sensor_name == "no_response" and not no_response_changed.done(): - no_response_changed.set_result(True) - elif ( - sensor_name == "exception_response" - and not exception_response_changed.done() - ): - exception_response_changed.set_result(True) + basic_register_changed = tracker.expect("basic_register", 259.0) + delayed_response_changed = tracker.expect("delayed_response", 255.0) + # late_response / no_response / exception_response: expect *any* value + # (these should never fire, so we use a permissive match via expect_any) + late_response_changed = tracker.expect_any("late_response") + no_response_changed = tracker.expect_any("no_response") + exception_response_changed = tracker.expect_any("exception_response") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() + await tracker.setup_and_start_scenario(client) - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) - - try: - await asyncio.wait_for(delayed_response_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for delayed_response change. Received sensor states:\n" - f" delayed_response: {sensor_states['delayed_response']}\n" - ) - - try: - await asyncio.wait_for(late_response_changed, timeout=2.0) - pytest.fail( - f"late_response change should not have been triggered, but was. Received sensor states:\n" - f" late_response: {sensor_states['late_response']}\n" - ) - except TimeoutError: - pass # Expected timeout since we never inject a response for late_response - - try: - await asyncio.wait_for(no_response_changed, timeout=2.0) - pytest.fail( - f"no_response change should not have been triggered, but was. Received sensor states:\n" - f" no_response: {sensor_states['no_response']}\n" - ) - except TimeoutError: - pass # Expected timeout since we never inject a response for no_response - - # Wait for basic register to be updated with successful parse - try: - await asyncio.wait_for(basic_register_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for Basic Register change. Received sensor states:\n" - f" basic_register: {sensor_states['basic_register']}\n" - ) - - try: - await asyncio.wait_for(exception_response_changed, timeout=2.0) - pytest.fail( - f"exception_response change should not have been triggered, but was. Received sensor states:\n" - f" exception_response: {sensor_states['exception_response']}\n" - ) - except TimeoutError: - pass + await tracker.await_change(delayed_response_changed, "delayed_response") + await tracker.await_change(basic_register_changed, "basic_register") + # Run all "must not change" checks concurrently — each waits the full + # timeout, so sequential execution would multiply the wall time. + await asyncio.gather( + tracker.await_must_not_change(late_response_changed, "late_response"), + tracker.await_must_not_change(no_response_changed, "no_response"), + tracker.await_must_not_change( + exception_response_changed, "exception_response" + ), + ) @pytest.mark.asyncio @@ -159,69 +125,17 @@ async def test_uart_mock_modbus_timing( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test basic modbus data parsing.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) + """Test modbus timing with multi-register SDM meter response.""" - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "sdm_voltage": [], - } - - voltage_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is a good voltage reading (243V) - if ( - sensor_name == "sdm_voltage" - and state.state > 200.0 - and not voltage_changed.done() - ): - voltage_changed.set_result(True) + tracker = SensorTracker(["sdm_voltage"]) + voltage_changed = tracker.expect_any("sdm_voltage") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() - - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) - - # Wait for voltage to be updated with successful parse - try: - await asyncio.wait_for(voltage_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for SDM voltage change. Received sensor states:\n" - f" sdm_voltage: {sensor_states['sdm_voltage']}\n" - ) + await tracker.setup_and_start_scenario(client) + await tracker.await_change(voltage_changed, "sdm_voltage") @pytest.mark.asyncio @@ -234,66 +148,187 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. + Bus-level warnings (CRC failures, buffer clears) are expected during + chunked reassembly — the test only verifies the final value arrives. """ - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "sdm_voltage": [], - } - - voltage_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is a good voltage reading (243V) - if ( - sensor_name == "sdm_voltage" - and state.state > 200.0 - and not voltage_changed.done() - ): - voltage_changed.set_result(True) + tracker = SensorTracker(["sdm_voltage"]) + voltage_changed = tracker.expect_any("sdm_voltage") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() + await tracker.setup_and_start_scenario(client) + await tracker.await_change(voltage_changed, "sdm_voltage") - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", + strict=True, +) +async def test_uart_mock_modbus_server( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server parsing with peer traffic on a shared bus.""" - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) + tracker = SensorTracker( + ["basic_read", "read_after_peer_response", "read_after_peer_timeout"] + ) + futures = tracker.expect_all( + { + "basic_read": 1, + "read_after_peer_response": 1, + "read_after_peer_timeout": 1, + } + ) - # Wait for voltage to be updated with successful parse - try: - await asyncio.wait_for(voltage_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for SDM voltage change. Received sensor states:\n" - f" sdm_voltage: {sensor_states['sdm_voltage']}\n" + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller functionality for all read register types.""" + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = { + "reg_u_word": 99, + "reg_s_word": -99, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(3.14), + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller write functionality for all register value types. + + Verifies that writing to modbus server registers via the controller updates + the server's stored values, which are then read back correctly on the next poll. + All 12 value types are tested: U/S_WORD, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + register_test_cases: dict[str, RegisterTestCase] = { + "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), + "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), + "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), + "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), + "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), + "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), + "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), + "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), + "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), + "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), + "reg_fp32": RegisterTestCase( + pytest.approx(1.5, abs=0.01), + "write_fp32", + 3.14, + pytest.approx(3.14, abs=0.01), + ), + "reg_fp32_r": RegisterTestCase( + pytest.approx(2.5, abs=0.01), + "write_fp32_r", + 6.28, + pytest.approx(6.28, abs=0.01), + ), + } + + tracker = SensorTracker(list(register_test_cases.keys())) + + # Phase 1: expect initial baseline values + initial_futures = tracker.expect_all( + {name: case.initial_value for name, case in register_test_cases.items()} + ) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + {name: case.post_write_value for name, case in register_test_cases.items()} + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Issue write commands for all register types + for case in register_test_cases.values(): + entity = find_entity(entities, case.write_number_name, NumberInfo) + assert entity is not None, ( + f"{case.write_number_name} number entity not found" ) + client.number_command(entity.key, case.write_value) + + # Wait for sensors to reflect the written values (round-trip write+read) + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", + strict=True, +) +async def test_uart_mock_modbus_server_controller_multiple( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller functionality with multiple servers.""" + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = {"reg_u_word": 919, "reg_u_word_2": 929} + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 28f111d758..948aabaa66 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1037,7 +1037,7 @@ def test_get_all_dependencies_platform_component() -> None: with ( patch("esphome.loader.get_component") as mock_get_component, - patch("helpers.get_platform") as mock_get_platform, + patch("esphome.loader.get_platform") as mock_get_platform, ): mock_get_platform.return_value = platform_comp mock_get_component.return_value = None @@ -1061,7 +1061,7 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None: with ( patch("esphome.loader.get_component") as mock_get_component, - patch("helpers.get_platform") as mock_get_platform, + patch("esphome.loader.get_platform") as mock_get_platform, ): mock_get_platform.return_value = platform_comp mock_get_component.side_effect = lambda name: ( 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/components/test_time.py b/tests/unit_tests/components/test_time.py new file mode 100644 index 0000000000..48988fb03f --- /dev/null +++ b/tests/unit_tests/components/test_time.py @@ -0,0 +1,80 @@ +"""Tests for time component cron expression parsing.""" + +from esphome.components.time import _parse_cron_part + + +def test_star_slash_seconds() -> None: + assert _parse_cron_part("*/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} + + +def test_star_slash_minutes() -> None: + assert _parse_cron_part("*/5", 0, 59, {}) == { + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + } + + +def test_star_slash_hours() -> None: + assert _parse_cron_part("*/2", 0, 23, {}) == { + 0, + 2, + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + } + + +def test_star_slash_days_of_month() -> None: + """days_of_month starts at 1, not 0.""" + assert _parse_cron_part("*/5", 1, 31, {}) == {1, 6, 11, 16, 21, 26, 31} + + +def test_question_slash() -> None: + assert _parse_cron_part("?/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} + + +def test_empty_offset_slash() -> None: + """Empty offset defaults to min_value.""" + assert _parse_cron_part("/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} + + +def test_empty_offset_slash_nonzero_min() -> None: + """Empty offset defaults to min_value, not 0.""" + assert _parse_cron_part("/5", 1, 31, {}) == {1, 6, 11, 16, 21, 26, 31} + + +def test_numeric_offset_slash() -> None: + assert _parse_cron_part("5/10", 0, 60, {}) == {5, 15, 25, 35, 45, 55} + + +def test_star() -> None: + assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60)) + + +def test_question() -> None: + assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60)) + + +def test_range() -> None: + assert _parse_cron_part("1-5", 0, 59, {}) == {1, 2, 3, 4, 5} + + +def test_single_value() -> None: + assert _parse_cron_part("30", 0, 59, {}) == {30} diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index d6cbb8c6be..e79ff850f9 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -28,6 +28,7 @@ from esphome.core.entity_helpers import ( get_base_entity_object_id, register_device_class, register_icon, + register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -925,6 +926,22 @@ def test_register_device_class_max_length() -> None: assert register_device_class("") == 0 +def test_register_unit_of_measurement_max_length() -> None: + """Test register_unit_of_measurement rejects units exceeding 63 characters.""" + # 63 chars should succeed + max_uom = "a" * 63 + idx = register_unit_of_measurement(max_uom) + assert idx > 0 + + # 64 chars should fail + too_long = "a" * 64 + with pytest.raises(ValueError, match="Unit of measurement string too long"): + register_unit_of_measurement(too_long) + + # Empty string returns 0 + assert register_unit_of_measurement("") == 0 + + @pytest.mark.asyncio async def test_setup_entity_with_entity_category( setup_test_environment: list[str], 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/fixtures/substitutions/11-include_path.approved.yaml b/tests/unit_tests/fixtures/substitutions/11-include_path.approved.yaml new file mode 100644 index 0000000000..d758a832a4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/11-include_path.approved.yaml @@ -0,0 +1,15 @@ +values: + - var1: 4 + - a: 5 + - b: 6 + - c: The value of C is 7 + - This value comes from inc2.yaml. x is 3, y is 4 + - From main config, x is 3, y is 2 + - $a $b $c are out of scope here + - keys_in_inc3: + x: 3 + y: 2 +substitutions: + x: 3 + y: 2 + include_file: inc1 diff --git a/tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml b/tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml new file mode 100644 index 0000000000..78b1cb4fb9 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/11-include_path.input.yaml @@ -0,0 +1,21 @@ +substitutions: + include_file: inc1 + x: 3 # override x from inc2.yaml + +packages: + my_package: !include + file: ${include_file + ".yaml"} # includes inc1.yaml + vars: + var1: 4 + a: ${x+2} + b: ${a+1} + c: 7 + other_package: !include + file: inc${1+1}.yaml # includes inc2.yaml + vars: + y: 4 + +values: + - From main config, x is $x, y is $y + - $a $b $c are out of scope here + - !include ${"inc" + "3.yaml"} # includes inc3.yaml here (not a package) diff --git a/tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml new file mode 100644 index 0000000000..02d8512498 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.approved.yaml @@ -0,0 +1,9 @@ +substitutions: + x: 7 +test_list: + - content: + before: Content before + after: Content after + keys_in_inc3: + x: 7 + y: 8 diff --git a/tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml new file mode 100644 index 0000000000..a03e66e393 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/12-yaml-merge.input.yaml @@ -0,0 +1,10 @@ +substitutions: + x: 7 +test_list: + - content: + before: Content before + <<: !include + file: inc3.yaml + vars: + y: 8 + after: Content after diff --git a/tests/unit_tests/fixtures/substitutions/inc2.yaml b/tests/unit_tests/fixtures/substitutions/inc2.yaml new file mode 100644 index 0000000000..29a1833efc --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/inc2.yaml @@ -0,0 +1,6 @@ +substitutions: + x: 1 + y: 2 + +values: + - This value comes from inc2.yaml. x is $x, y is $y diff --git a/tests/unit_tests/fixtures/substitutions/inc3.yaml b/tests/unit_tests/fixtures/substitutions/inc3.yaml new file mode 100644 index 0000000000..03d459dc97 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/inc3.yaml @@ -0,0 +1,3 @@ +keys_in_inc3: + x: ${x} + y: ${y} 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 c1849daf4b..ac84ce7cc8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -149,17 +149,36 @@ def test_icon__invalid(): def test_icon__max_length(): - """Test that icons exceeding 63 characters are rejected.""" - # Exactly 63 chars should pass - max_icon = "mdi:" + "a" * 59 # 63 chars total + """Test that icons exceeding 63 bytes are rejected.""" + # Exactly 63 bytes should pass + max_icon = "mdi:" + "a" * 59 # 63 bytes total assert config_validation.icon(max_icon) == max_icon - # 64 chars should fail - too_long = "mdi:" + "a" * 60 # 64 chars total + # 64 bytes should fail + too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): config_validation.icon(too_long) +def test_byte_length() -> None: + """Test ByteLength validator checks UTF-8 byte length, not char count.""" + validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + + # ASCII: 10 chars = 10 bytes, should pass + assert validator("a" * 10) == "a" * 10 + + # ASCII: 11 chars = 11 bytes, should fail + with pytest.raises(Invalid, match="too long.*11 bytes.*max 10"): + validator("a" * 11) + + # Multibyte: 3 chars × 3 bytes = 9 bytes, should pass + assert validator("温度传") == "温度传" + + # Multibyte: 4 chars × 3 bytes = 12 bytes, should fail + with pytest.raises(Invalid, match="too long.*12 bytes.*max 10"): + validator("温度传感") + + @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): assert config_validation.boolean(value) is True @@ -567,14 +586,23 @@ def test_validate_entity_name__slash_replaced_with_warning( def test_validate_entity_name__max_length() -> None: - # 120 chars should pass + # 120 bytes should pass assert config_validation._validate_entity_name("x" * 120) == "x" * 120 - # 121 chars should fail - with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): + # 121 bytes should fail + with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): config_validation._validate_entity_name("x" * 121) +def test_validate_entity_name__multibyte_byte_length() -> None: + # 40 chars of 3-byte UTF-8 = 120 bytes, should pass + assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + + # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) + with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): + config_validation._validate_entity_name("温" * 41) + + def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None @@ -588,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_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index bdc31cdef8..81ae586e23 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -669,11 +669,11 @@ async def test_templatable__int_with_std_string() -> None: @pytest.mark.asyncio async def test_templatable__string_with_non_string_output_type() -> None: - """Static string with non-std::string output_type returns raw string.""" + """Static string with non-std::string output_type returns stateless lambda.""" result = await cg.templatable("hello", [], ct.bool_) - assert isinstance(result, str) - assert result == "hello" + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" @pytest.mark.asyncio @@ -684,6 +684,15 @@ async def test_templatable__with_to_exp_callable() -> None: assert result == 84 +@pytest.mark.asyncio +async def test_templatable__with_to_exp_callable_and_output_type() -> None: + """When to_exp is provided with non-string output_type, result is lambda-wrapped.""" + result = await cg.templatable(42, [], ct.int_, to_exp=lambda x: x * 2) + + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" + + @pytest.mark.asyncio async def test_templatable__with_to_exp_dict() -> None: """When to_exp is a dict, value is looked up.""" 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_substitutions.py b/tests/unit_tests/test_substitutions.py index c7b0bbcf7c..01c669e542 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -8,12 +8,17 @@ import pytest from esphome import config as config_module, yaml_util from esphome.components import substitutions -from esphome.components.packages import do_packages_pass, merge_packages +from esphome.components.packages import ( + MAX_INCLUDE_DEPTH, + _PackageProcessor, + do_packages_pass, + merge_packages, +) from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv from esphome.const import CONF_SUBSTITUTIONS -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.util import OrderedDict _LOGGER = logging.getLogger(__name__) @@ -630,3 +635,62 @@ def test_do_substitution_pass_substitutions_must_be_mapping_from_config() -> Non cv.Invalid, match="Substitutions must be a key to value mapping" ): substitutions.do_substitution_pass(config) + + +# ── IncludeFile / package loading tests ──────────────────────────────────── + + +def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: + """A yaml_loader that always returns another IncludeFile triggers the depth guard.""" + parent = tmp_path / "main.yaml" + parent.write_text("") + + # Each call to the loader returns a fresh IncludeFile pointing at itself, + # so PACKAGE_SCHEMA always sees an IncludeFile and never a dict. + def always_returns_include(path: Path) -> yaml_util.IncludeFile: + return yaml_util.IncludeFile(parent, path.name, None, always_returns_include) + + package_config = yaml_util.IncludeFile( + parent, "test.yaml", None, always_returns_include + ) + processor = _PackageProcessor({}, None, False) + with pytest.raises( + cv.Invalid, + match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", + ): + processor.resolve_package(package_config, substitutions.ContextVars()) + + +def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: + """!include with an undefined substitution variable raises cv.Invalid. + + The error message must reference the unresolved filename template so the + user knows which include failed, rather than seeing a bare file-not-found. + """ + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include ${undefined_var}.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises(cv.Invalid, match=r"\$\{undefined_var\}"): + substitutions.do_substitution_pass(config) + + +def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: + """An undefined substitution in a package include filename raises cv.Invalid. + + Previously this would raise an unhandled UndefinedError. With + strict_undefined=False, the unresolved filename passes through to + file loading which produces a clean cv.Invalid error. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + raise EsphomeError(f"Error reading file {path}: No such file") + + package_config = yaml_util.IncludeFile( + parent, "${undefined_var}.yaml", None, loader + ) + processor = _PackageProcessor({}, None, False) + with pytest.raises(cv.Invalid, match="unresolved substitutions"): + processor.resolve_package(package_config, substitutions.ContextVars()) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 667b593819..bfd60de44d 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,3 +1,4 @@ +import io from pathlib import Path import shutil from unittest.mock import patch @@ -7,6 +8,7 @@ import pytest from esphome import core, yaml_util from esphome.components import substitutions from esphome.config_helpers import Extend, Remove +import esphome.config_validation as cv from esphome.core import EsphomeError from esphome.util import OrderedDict @@ -74,7 +76,9 @@ def test_parsing_with_custom_loader(fixture_path): loader_calls.append(fname) with yaml_file.open(encoding="utf-8") as f_handle: - yaml_util.parse_yaml(yaml_file, f_handle, custom_loader) + config = yaml_util.parse_yaml(yaml_file, f_handle, custom_loader) + # substitute config to expand includes: + substitutions.substitute(config, [], substitutions.ContextVars(), False) assert len(loader_calls) == 3 assert loader_calls[0].parts[-2:] == ("includes", "included.yaml") @@ -323,6 +327,62 @@ 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: + result = yaml_util.load_yaml(main) + # !include is deferred; resolve it to trigger the nested load + result["child"].load() + + 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", [ @@ -446,3 +506,209 @@ def test_represent_extend() -> None: def test_represent_remove() -> None: """Test that Remove objects are dumped as plain !remove scalars.""" assert yaml_util.dump({"key": Remove("my_id")}) == "key: !remove 'my_id'\n" + + +def test_represent_include_file() -> None: + """Test that IncludeFile objects are dumped as !include scalars.""" + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), "path/to/file.yaml", None, lambda _: {} + ) + assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n" + + +def test_represent_include_file_with_vars() -> None: + """Test that IncludeFile with vars is dumped as !include mapping form.""" + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), + "path/to/file.yaml", + {"key": "value"}, + lambda _: {}, + ) + result = yaml_util.dump({"key": include}) + assert "!include" in result + assert "file: path/to/file.yaml" in result + assert "key: value" in result + + +def test_represent_include_file_with_data_base_mixin() -> None: + """Test that IncludeFile wrapped with ESPHomeDataBase mixin is also dumped correctly. + + The YAML loader wraps IncludeFile via add_class_to_obj, creating a dynamic + subclass. add_multi_representer must match this subclass through the MRO. + """ + include = yaml_util.IncludeFile( + Path("/fake/main.yaml"), "common/spi.yaml", None, lambda _: {} + ) + wrapped = yaml_util.make_data_base(include) + assert isinstance(wrapped, yaml_util.ESPHomeDataBase) + assert yaml_util.dump({"pkg": wrapped}) == "pkg: !include 'common/spi.yaml'\n" + + +# ── IncludeFile unit tests ────────────────────────────────────────────────── + + +def test_include_file_repr(tmp_path: Path) -> None: + """repr() includes the filename so it appears usefully in error messages.""" + parent = tmp_path / "main.yaml" + include = yaml_util.IncludeFile(parent, "some/nested.yaml", None, lambda _: {}) + assert repr(include) == "IncludeFile(some/nested.yaml)" + + +def test_include_file_load_caches_result(tmp_path: Path) -> None: + """load() invokes the yaml_loader only once; subsequent calls return the cached object.""" + parent = tmp_path / "main.yaml" + content = {"key": "value"} + call_count = 0 + + def counting_loader(_): + nonlocal call_count + call_count += 1 + return content + + include = yaml_util.IncludeFile(parent, "child.yaml", None, counting_loader) + first = include.load() + second = include.load() + + assert call_count == 1 + assert first is second + + +def test_include_file_load_caches_none_result(tmp_path: Path) -> None: + """load() caches None content (empty YAML files) and does not re-invoke the loader.""" + parent = tmp_path / "main.yaml" + call_count = 0 + + def counting_loader(_): + nonlocal call_count + call_count += 1 + + include = yaml_util.IncludeFile(parent, "empty.yaml", None, counting_loader) + first = include.load() + second = include.load() + + assert call_count == 1 + assert first is None + assert second is None + + +def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> None: + """load() raises if the filename contains unresolved substitutions or expressions.""" + parent = tmp_path / "main.yaml" + include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", None, lambda _: {}) + with pytest.raises(cv.Invalid, match="unresolved"): + include.load() + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("device-${platform}.yaml", True), + ("$platform.yaml", True), + ("${a + b}.yaml", True), # Jinja expression + ("device.yaml", False), + ("path/to/device.yaml", False), + ("my$file.yaml", True), # $file is a valid substitution + ("price-100$.yaml", False), # $ at end, not followed by valid substitution + ], +) +def test_include_file_has_unresolved_expressions( + tmp_path: Path, filename: str, expected: bool +) -> None: + """has_unresolved_expressions() detects substitution patterns in the filename.""" + parent = tmp_path / "main.yaml" + include = yaml_util.IncludeFile(parent, filename, None, lambda _: {}) + assert include.has_unresolved_expressions() == expected + + +def test_include_in_list_context() -> None: + """!include of a file returning a list is handled correctly, + including when that list itself contains a nested IncludeFile.""" + parent = Path("/fake/main.yaml") + + # The nested IncludeFile resolves to a plain string value + inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: "gamma") + + # The outer IncludeFile returns a list whose last element is itself an IncludeFile, + # exercising the substitution pass's ability to recurse into loaded content. + outer = yaml_util.IncludeFile( + parent, "items.yaml", None, lambda _: ["alpha", "beta", inner] + ) + + config = OrderedDict({"values": outer}) + config = substitutions.do_substitution_pass(config) + + assert config["values"] == ["alpha", "beta", "gamma"] + + +def test_top_level_include_resolved_by_load_yaml(tmp_path: Path) -> None: + """load_yaml resolves a top-level !include so callers always get a dict.""" + child = tmp_path / "child.yaml" + child.write_text("key: value\n") + main = tmp_path / "main.yaml" + main.write_text("!include child.yaml\n") + + result = yaml_util.load_yaml(main) + assert isinstance(result, dict) + assert result["key"] == "value" + + +def test_include_plain_filename_loads_after_deferred_refactor() -> None: + """!include with a plain filename (no $ expressions) still loads correctly. + + Regression guard: the deferred-loading refactor must not break the simple case. + """ + parent = Path("/fake/main.yaml") + include = yaml_util.IncludeFile( + parent, "child.yaml", None, lambda _: {"answer": 42} + ) + + config = OrderedDict({"result": include}) + config = substitutions.do_substitution_pass(config) + + assert config["result"]["answer"] == 42 + + +def test_yaml_merge_include_with_filename_substitution_raises() -> None: + """<<: !include ${expr} raises a clear error — substitutions in merge-key filenames + are not yet supported, and the error message must say so.""" + yaml_text = "base:\n existing: value\n <<: !include ${filename}.yaml\n" + with pytest.raises(EsphomeError, match="not supported yet"): + yaml_util.parse_yaml( + Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {} + ) + + +def test_yaml_merge_list_include_with_filename_substitution_raises() -> None: + """Substitutions in include filenames within merge-key lists raise a clear error.""" + yaml_text = "base:\n existing: value\n <<:\n - !include ${filename}.yaml\n" + with pytest.raises(EsphomeError, match="not supported yet"): + yaml_util.parse_yaml( + Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {} + ) + + +def test_yaml_merge_chain_include_resolves() -> None: + """Chained includes in merge keys resolve through multiple IncludeFile layers.""" + parent = Path("/fake/main.yaml") + + inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: {"x": 1}) + outer = yaml_util.IncludeFile(parent, "outer.yaml", None, lambda _: inner) + + yaml_text = "base:\n existing: value\n <<: !include outer.yaml\n" + config = yaml_util.parse_yaml(parent, io.StringIO(yaml_text), lambda _: outer) + config = substitutions.do_substitution_pass(config) + + assert config["base"]["x"] == 1 + assert config["base"]["existing"] == "value" + + +def test_yaml_merge_chain_include_depth_exceeded() -> None: + """Chain includes in merge keys exceeding depth limit raise a clear error.""" + parent = Path("/fake/main.yaml") + + def self_referencing_loader(path: Path) -> yaml_util.IncludeFile: + return yaml_util.IncludeFile(parent, path.name, None, self_referencing_loader) + + yaml_text = "base:\n <<: !include loop.yaml\n" + with pytest.raises(EsphomeError, match="Maximum include chain depth"): + yaml_util.parse_yaml(parent, io.StringIO(yaml_text), self_referencing_loader)