1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-10 09:42:01 +00:00

Compare commits

..

24 Commits

Author SHA1 Message Date
J. Nick Koston
39bde3eb73 Revert early returns in process_active_iterator_()
The if/else pattern produces smaller code than early returns.
2026-02-09 20:21:02 -06:00
J. Nick Koston
c53baf70c7 Collapse else chain in process_active_iterator_() with early returns 2026-02-09 20:16:09 -06:00
J. Nick Koston
041c43fb32 Add INITIAL_STATE comment to else branch 2026-02-09 20:14:54 -06:00
J. Nick Koston
b4741ade0d Remove unreachable NONE case from process_active_iterator_() 2026-02-09 20:13:57 -06:00
J. Nick Koston
2c3a92db97 Add forward declaration for ComponentIterator in api_connection.h 2026-02-09 20:11:45 -06:00
J. Nick Koston
fc91a4d7a3 Extract iterator switch from loop() into process_active_iterator_()
The iterator switch only runs during initial connection handshake.
In steady state active_iterator_ is always NONE, so the entire
switch block is cold code that wastes icache in the hot loop path.
2026-02-09 20:10:44 -06:00
J. Nick Koston
9bf90eff01 [api] De-duplicate iterator batch processing in APIConnection::loop()
Replace the process_iterator_batch_ template (instantiated separately
for ListEntitiesIterator and InitialStateIterator) with a single
non-template method taking a ComponentIterator& base class reference.

Both iterators inherit from ComponentIterator, and the template only
called completed() and advance() which are both base class methods.
The two template instantiations generated ~160 bytes of duplicate code
in APIConnection::loop(). The single non-template version is 79 bytes,
saving 61 bytes net flash and removing 140 bytes of cold
connect/reconnect code from the hot loop path.

Also move the duplicated completed() method from ListEntitiesIterator
and InitialStateIterator to the ComponentIterator base class where
state_ is defined.
2026-02-09 19:57:29 -06:00
Jonathan Swoboda
dcbb020479 [uart] Fix available() return type to size_t across components (#13898)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:02:41 -05:00
J. Nick Koston
87ac263264 [dsmr] Batch UART reads to reduce per-loop overhead (#13826) 2026-02-10 00:32:52 +00:00
Sean Kelly
097901e9c8 [aqi] Fix AQI calculation for specific pm2.5 or pm10 readings (#13770) 2026-02-09 19:30:37 -05:00
J. Nick Koston
01a90074ba [ld2420] Batch UART reads to reduce loop overhead (#13821)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-02-10 00:25:34 +00:00
J. Nick Koston
57b85a8400 [dlms_meter] Batch UART reads to reduce per-loop overhead (#13828)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-02-10 00:24:20 +00:00
J. Nick Koston
2edfcf278f [hlk_fm22x] Replace per-cycle vector allocation with member buffer (#13859) 2026-02-09 18:21:10 -06:00
J. Nick Koston
bcd4a9fc39 [pylontech] Batch UART reads to reduce loop overhead (#13824) 2026-02-09 18:20:53 -06:00
J. Nick Koston
78df8be31f [logger] Resolve thread name once and pass through logging chain (#13836) 2026-02-09 18:16:27 -06:00
J. Nick Koston
dacc557a16 [uart] Convert parity_to_str to PROGMEM_STRING_TABLE (#13805) 2026-02-09 18:15:48 -06:00
J. Nick Koston
3767c5ec91 [scheduler] Make core timer ID collisions impossible with type-safe internal IDs (#13882)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-02-09 16:48:08 -06:00
George Joseph
7c1327f96a [mipi_dsi] Add WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD 3.4C and 4C (#13840) 2026-02-10 09:44:47 +11:00
Jonathan Swoboda
475db750e0 [uart] Change available() return type from int to size_t (#13893)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:41:16 -05:00
dependabot[bot]
8f74b027b4 Bump setuptools from 80.10.2 to 82.0.0 (#13897) 2026-02-09 16:40:32 -06:00
tomaszduda23
b2b9e0cb0a [nrf52,zigee] print reporting status (#13890)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-02-09 16:00:08 -05:00
tronikos
dbf202bf0d Add get_away and get_on in WaterHeaterCall and deprecate get_state (#13891) 2026-02-09 20:57:36 +00:00
J. Nick Koston
b6fdd29953 [voice_assistant] Replace timer unordered_map with vector to eliminate per-tick heap allocation (#13857) 2026-02-09 14:42:40 -06:00
Clyde Stubbs
00256e3ca0 [mipi_rgb] Allow use on P4 (#13740) 2026-02-10 06:35:41 +11:00
97 changed files with 823 additions and 2853 deletions

View File

@@ -965,38 +965,6 @@ 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
@@ -1274,7 +1242,6 @@ POST_CONFIG_ACTIONS = {
"rename": command_rename,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
"bundle": command_bundle,
}
SIMPLE_CONFIG_ACTIONS = [
@@ -1578,24 +1545,6 @@ 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 <config> <command>.
#
@@ -1674,16 +1623,6 @@ 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

View File

@@ -1,699 +0,0 @@
"""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 <key>`` 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

View File

@@ -219,35 +219,8 @@ void APIConnection::loop() {
this->process_batch_();
}
switch (this->active_iterator_) {
case ActiveIterator::LIST_ENTITIES:
if (this->iterator_storage_.list_entities.completed()) {
this->destroy_active_iterator_();
if (this->flags_.state_subscription) {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
} else {
this->process_iterator_batch_(this->iterator_storage_.list_entities);
}
break;
case ActiveIterator::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();
} else {
this->process_iterator_batch_(this->iterator_storage_.initial_state);
}
break;
case ActiveIterator::NONE:
break;
if (this->active_iterator_ != ActiveIterator::NONE) {
this->process_active_iterator_();
}
if (this->flags_.sent_ping) {
@@ -283,6 +256,49 @@ void APIConnection::loop() {
#endif
}
void APIConnection::process_active_iterator_() {
// Caller ensures active_iterator_ != NONE
if (this->active_iterator_ == ActiveIterator::LIST_ENTITIES) {
if (this->iterator_storage_.list_entities.completed()) {
this->destroy_active_iterator_();
if (this->flags_.state_subscription) {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
} else {
this->process_iterator_batch_(this->iterator_storage_.list_entities);
}
} 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();
} else {
this->process_iterator_batch_(this->iterator_storage_.initial_state);
}
}
}
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
size_t initial_size = this->deferred_batch_.size();
size_t max_batch = this->get_max_batch_size_();
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
// If the batch is full, process it immediately
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
if (this->deferred_batch_.size() >= max_batch) {
this->process_batch_();
}
}
bool APIConnection::send_disconnect_response_() {
// remote initiated disconnect_client
// don't close yet, we still need to send the disconnect response

View File

@@ -15,6 +15,10 @@
#include <limits>
#include <vector>
namespace esphome {
class ComponentIterator;
} // namespace esphome
namespace esphome::api {
// Keepalive timeout in milliseconds
@@ -364,20 +368,13 @@ class APIConnection final : public APIServerConnectionBase {
return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
}
// Helper method to process multiple entities from an iterator in a batch
template<typename Iterator> void process_iterator_batch_(Iterator &iterator) {
size_t initial_size = this->deferred_batch_.size();
size_t max_batch = this->get_max_batch_size_();
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
// Process active iterator (list_entities/initial_state) during connection setup.
// Extracted from loop() — only runs during initial handshake, NONE in steady state.
void __attribute__((noinline)) process_active_iterator_();
// If the batch is full, process it immediately
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
if (this->deferred_batch_.size() >= max_batch) {
this->process_batch_();
}
}
// Helper method to process multiple entities from an iterator in a batch.
// Takes ComponentIterator base class reference to avoid duplicate template instantiations.
void process_iterator_batch_(ComponentIterator &iterator);
#ifdef USE_BINARY_SENSOR
static uint16_t try_send_binary_sensor_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);

View File

@@ -94,7 +94,6 @@ class ListEntitiesIterator : public ComponentIterator {
bool on_update(update::UpdateEntity *entity) override;
#endif
bool on_end() override;
bool completed() { return this->state_ == IteratorState::NONE; }
protected:
APIConnection *client_;

View File

@@ -88,7 +88,6 @@ class InitialStateIterator : public ComponentIterator {
#ifdef USE_UPDATE
bool on_update(update::UpdateEntity *entity) override;
#endif
bool completed() { return this->state_ == IteratorState::NONE; }
protected:
APIConnection *client_;

View File

@@ -1,5 +1,6 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <limits>
#include "abstract_aqi_calculator.h"
@@ -14,7 +15,11 @@ class AQICalculator : public AbstractAQICalculator {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return static_cast<uint16_t>(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index));
float aqi = std::max(pm2_5_index, pm10_0_index);
if (aqi < 0.0f) {
aqi = 0.0f;
}
return static_cast<uint16_t>(std::lround(aqi));
}
protected:
@@ -22,13 +27,27 @@ class AQICalculator : public AbstractAQICalculator {
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {{0.0f, 9.0f}, {9.1f, 35.4f},
{35.5f, 55.4f}, {55.5f, 125.4f},
{125.5f, 225.4f}, {225.5f, std::numeric_limits<float>::max()}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
// clang-format off
{0.0f, 9.1f},
{9.1f, 35.5f},
{35.5f, 55.5f},
{55.5f, 125.5f},
{125.5f, 225.5f},
{225.5f, std::numeric_limits<float>::max()}
// clang-format on
};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {{0.0f, 54.0f}, {55.0f, 154.0f},
{155.0f, 254.0f}, {255.0f, 354.0f},
{355.0f, 424.0f}, {425.0f, std::numeric_limits<float>::max()}};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
// clang-format off
{0.0f, 55.0f},
{55.0f, 155.0f},
{155.0f, 255.0f},
{255.0f, 355.0f},
{355.0f, 425.0f},
{425.0f, std::numeric_limits<float>::max()}
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array);
@@ -45,7 +64,10 @@ class AQICalculator : public AbstractAQICalculator {
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
if (in_range) {
return i;
}
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <limits>
#include "abstract_aqi_calculator.h"
@@ -12,7 +13,11 @@ class CAQICalculator : public AbstractAQICalculator {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return static_cast<uint16_t>(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index));
float aqi = std::max(pm2_5_index, pm10_0_index);
if (aqi < 0.0f) {
aqi = 0.0f;
}
return static_cast<uint16_t>(std::lround(aqi));
}
protected:
@@ -21,10 +26,24 @@ class CAQICalculator : public AbstractAQICalculator {
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
{0.0f, 15.0f}, {15.1f, 30.0f}, {30.1f, 55.0f}, {55.1f, 110.0f}, {110.1f, std::numeric_limits<float>::max()}};
// clang-format off
{0.0f, 15.1f},
{15.1f, 30.1f},
{30.1f, 55.1f},
{55.1f, 110.1f},
{110.1f, std::numeric_limits<float>::max()}
// clang-format on
};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
{0.0f, 25.0f}, {25.1f, 50.0f}, {50.1f, 90.0f}, {90.1f, 180.0f}, {180.1f, std::numeric_limits<float>::max()}};
// clang-format off
{0.0f, 25.1f},
{25.1f, 50.1f},
{50.1f, 90.1f},
{90.1f, 180.1f},
{180.1f, std::numeric_limits<float>::max()}
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array);
@@ -42,7 +61,10 @@ class CAQICalculator : public AbstractAQICalculator {
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
if (in_range) {
return i;
}
}

View File

@@ -46,16 +46,16 @@ static const uint32_t PKT_TIMEOUT_MS = 200;
void BL0942::loop() {
DataPacket buffer;
int avail = this->available();
size_t avail = this->available();
if (!avail) {
return;
}
if (static_cast<size_t>(avail) < sizeof(buffer)) {
if (avail < sizeof(buffer)) {
if (!this->rx_start_) {
this->rx_start_ = millis();
} else if (millis() > this->rx_start_ + PKT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Junk on wire. Throwing away partial message (%d bytes)", avail);
ESP_LOGW(TAG, "Junk on wire. Throwing away partial message (%zu bytes)", avail);
this->read_array((uint8_t *) &buffer, avail);
this->rx_start_ = 0;
}

View File

@@ -16,8 +16,8 @@ void CSE7766Component::loop() {
}
// Early return prevents updating last_transmission_ when no data is available.
int avail = this->available();
if (avail <= 0) {
size_t avail = this->available();
if (avail == 0) {
return;
}
@@ -27,7 +27,7 @@ void CSE7766Component::loop() {
// At 4800 baud (~480 bytes/sec) with ~122 Hz loop rate, typically ~4 bytes per call.
uint8_t buf[CSE7766_RAW_DATA_SIZE];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -133,10 +133,10 @@ void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) {
void DFPlayer::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -28,15 +28,28 @@ void DlmsMeterComponent::dump_config() {
void DlmsMeterComponent::loop() {
// Read while data is available, netznoe uses two frames so allow 2x max frame length
while (this->available()) {
if (this->receive_buffer_.size() >= MBUS_MAX_FRAME_LENGTH * 2) {
size_t avail = this->available();
if (avail > 0) {
size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size();
if (remaining == 0) {
ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes");
break;
} else {
// Read all available bytes in batches to reduce UART call overhead.
// Cap reads to remaining buffer capacity.
if (avail > remaining) {
avail = remaining;
}
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}
avail -= to_read;
this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read);
this->last_read_ = millis();
}
}
uint8_t c;
this->read_byte(&c);
this->receive_buffer_.push_back(c);
this->last_read_ = millis();
}
if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) {

View File

@@ -40,9 +40,7 @@ bool Dsmr::ready_to_request_data_() {
this->start_requesting_data_();
}
if (!this->requesting_data_) {
while (this->available()) {
this->read();
}
this->drain_rx_buffer_();
}
}
return this->requesting_data_;
@@ -115,138 +113,169 @@ void Dsmr::stop_requesting_data_() {
} else {
ESP_LOGV(TAG, "Stop reading data from P1 port");
}
while (this->available()) {
this->read();
}
this->drain_rx_buffer_();
this->requesting_data_ = false;
}
}
void Dsmr::drain_rx_buffer_() {
uint8_t buf[64];
size_t avail;
while ((avail = this->available()) > 0) {
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
break;
}
}
}
void Dsmr::reset_telegram_() {
this->header_found_ = false;
this->footer_found_ = false;
this->bytes_read_ = 0;
this->crypt_bytes_read_ = 0;
this->crypt_telegram_len_ = 0;
this->last_read_time_ = 0;
}
void Dsmr::receive_telegram_() {
while (this->available_within_timeout_()) {
const char c = this->read();
// Read all available bytes in batches to reduce UART call overhead.
uint8_t buf[64];
size_t avail = this->available();
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read))
return;
avail -= to_read;
// Find a new telegram header, i.e. forward slash.
if (c == '/') {
ESP_LOGV(TAG, "Header of telegram found");
this->reset_telegram_();
this->header_found_ = true;
}
if (!this->header_found_)
continue;
for (size_t i = 0; i < to_read; i++) {
const char c = static_cast<char>(buf[i]);
// Check for buffer overflow.
if (this->bytes_read_ >= this->max_telegram_len_) {
this->reset_telegram_();
ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_);
return;
}
// Find a new telegram header, i.e. forward slash.
if (c == '/') {
ESP_LOGV(TAG, "Header of telegram found");
this->reset_telegram_();
this->header_found_ = true;
}
if (!this->header_found_)
continue;
// Some v2.2 or v3 meters will send a new value which starts with '('
// in a new line, while the value belongs to the previous ObisId. For
// proper parsing, remove these new line characters.
if (c == '(') {
while (true) {
auto previous_char = this->telegram_[this->bytes_read_ - 1];
if (previous_char == '\n' || previous_char == '\r') {
this->bytes_read_--;
} else {
break;
// Check for buffer overflow.
if (this->bytes_read_ >= this->max_telegram_len_) {
this->reset_telegram_();
ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_);
return;
}
// Some v2.2 or v3 meters will send a new value which starts with '('
// in a new line, while the value belongs to the previous ObisId. For
// proper parsing, remove these new line characters.
if (c == '(') {
while (true) {
auto previous_char = this->telegram_[this->bytes_read_ - 1];
if (previous_char == '\n' || previous_char == '\r') {
this->bytes_read_--;
} else {
break;
}
}
}
// Store the byte in the buffer.
this->telegram_[this->bytes_read_] = c;
this->bytes_read_++;
// Check for a footer, i.e. exclamation mark, followed by a hex checksum.
if (c == '!') {
ESP_LOGV(TAG, "Footer of telegram found");
this->footer_found_ = true;
continue;
}
// Check for the end of the hex checksum, i.e. a newline.
if (this->footer_found_ && c == '\n') {
// Parse the telegram and publish sensor values.
this->parse_telegram();
this->reset_telegram_();
return;
}
}
}
// Store the byte in the buffer.
this->telegram_[this->bytes_read_] = c;
this->bytes_read_++;
// Check for a footer, i.e. exclamation mark, followed by a hex checksum.
if (c == '!') {
ESP_LOGV(TAG, "Footer of telegram found");
this->footer_found_ = true;
continue;
}
// Check for the end of the hex checksum, i.e. a newline.
if (this->footer_found_ && c == '\n') {
// Parse the telegram and publish sensor values.
this->parse_telegram();
this->reset_telegram_();
return;
}
}
}
void Dsmr::receive_encrypted_telegram_() {
while (this->available_within_timeout_()) {
const char c = this->read();
// Read all available bytes in batches to reduce UART call overhead.
uint8_t buf[64];
size_t avail = this->available();
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read))
return;
avail -= to_read;
// Find a new telegram start byte.
if (!this->header_found_) {
if ((uint8_t) c != 0xDB) {
continue;
for (size_t i = 0; i < to_read; i++) {
const char c = static_cast<char>(buf[i]);
// Find a new telegram start byte.
if (!this->header_found_) {
if ((uint8_t) c != 0xDB) {
continue;
}
ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found");
this->reset_telegram_();
this->header_found_ = true;
}
// Check for buffer overflow.
if (this->crypt_bytes_read_ >= this->max_telegram_len_) {
this->reset_telegram_();
ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_);
return;
}
// Store the byte in the buffer.
this->crypt_telegram_[this->crypt_bytes_read_] = c;
this->crypt_bytes_read_++;
// Read the length of the incoming encrypted telegram.
if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) {
// Complete header + data bytes
this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]);
ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_);
}
// Check for the end of the encrypted telegram.
if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) {
continue;
}
ESP_LOGV(TAG, "End of encrypted telegram found");
// Decrypt the encrypted telegram.
GCM<AES128> *gcmaes128{new GCM<AES128>()};
gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize());
// the iv is 8 bytes of the system title + 4 bytes frame counter
// system title is at byte 2 and frame counter at byte 15
for (int i = 10; i < 14; i++)
this->crypt_telegram_[i] = this->crypt_telegram_[i + 4];
constexpr uint16_t iv_size{12};
gcmaes128->setIV(&this->crypt_telegram_[2], iv_size);
gcmaes128->decrypt(reinterpret_cast<uint8_t *>(this->telegram_),
// the ciphertext start at byte 18
&this->crypt_telegram_[18],
// cipher size
this->crypt_bytes_read_ - 17);
delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory)
this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_);
ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_);
ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_);
// Parse the decrypted telegram and publish sensor values.
this->parse_telegram();
this->reset_telegram_();
return;
}
ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found");
this->reset_telegram_();
this->header_found_ = true;
}
// Check for buffer overflow.
if (this->crypt_bytes_read_ >= this->max_telegram_len_) {
this->reset_telegram_();
ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_);
return;
}
// Store the byte in the buffer.
this->crypt_telegram_[this->crypt_bytes_read_] = c;
this->crypt_bytes_read_++;
// Read the length of the incoming encrypted telegram.
if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) {
// Complete header + data bytes
this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]);
ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_);
}
// Check for the end of the encrypted telegram.
if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) {
continue;
}
ESP_LOGV(TAG, "End of encrypted telegram found");
// Decrypt the encrypted telegram.
GCM<AES128> *gcmaes128{new GCM<AES128>()};
gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize());
// the iv is 8 bytes of the system title + 4 bytes frame counter
// system title is at byte 2 and frame counter at byte 15
for (int i = 10; i < 14; i++)
this->crypt_telegram_[i] = this->crypt_telegram_[i + 4];
constexpr uint16_t iv_size{12};
gcmaes128->setIV(&this->crypt_telegram_[2], iv_size);
gcmaes128->decrypt(reinterpret_cast<uint8_t *>(this->telegram_),
// the ciphertext start at byte 18
&this->crypt_telegram_[18],
// cipher size
this->crypt_bytes_read_ - 17);
delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory)
this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_);
ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_);
ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_);
// Parse the decrypted telegram and publish sensor values.
this->parse_telegram();
this->reset_telegram_();
return;
}
}

View File

@@ -85,6 +85,7 @@ class Dsmr : public Component, public uart::UARTDevice {
void receive_telegram_();
void receive_encrypted_telegram_();
void reset_telegram_();
void drain_rx_buffer_();
/// Wait for UART data to become available within the read timeout.
///

View File

@@ -1,20 +1,16 @@
#include "hlk_fm22x.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <array>
#include <cinttypes>
namespace esphome::hlk_fm22x {
static const char *const TAG = "hlk_fm22x";
// Maximum response size is 36 bytes (VERIFY reply: face_id + 32-byte name)
static constexpr size_t HLK_FM22X_MAX_RESPONSE_SIZE = 36;
void HlkFm22xComponent::setup() {
ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X...");
this->set_enrolling_(false);
while (this->available()) {
while (this->available() > 0) {
this->read();
}
this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); });
@@ -35,7 +31,7 @@ void HlkFm22xComponent::update() {
}
void HlkFm22xComponent::enroll_face(const std::string &name, HlkFm22xFaceDirection direction) {
if (name.length() > 31) {
if (name.length() > HLK_FM22X_NAME_SIZE - 1) {
ESP_LOGE(TAG, "enroll_face(): name too long '%s'", name.c_str());
return;
}
@@ -88,7 +84,7 @@ void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *da
}
this->wait_cycles_ = 0;
this->active_command_ = command;
while (this->available())
while (this->available() > 0)
this->read();
this->write((uint8_t) (START_CODE >> 8));
this->write((uint8_t) (START_CODE & 0xFF));
@@ -137,17 +133,24 @@ void HlkFm22xComponent::recv_command_() {
checksum ^= byte;
length |= byte;
std::vector<uint8_t> data;
data.reserve(length);
if (length > HLK_FM22X_MAX_RESPONSE_SIZE) {
ESP_LOGE(TAG, "Response too large: %u bytes", length);
// Discard exactly the remaining payload and checksum for this frame
for (uint16_t i = 0; i < length + 1 && this->available() > 0; ++i)
this->read();
return;
}
for (uint16_t idx = 0; idx < length; ++idx) {
byte = this->read();
checksum ^= byte;
data.push_back(byte);
this->recv_buf_[idx] = byte;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)];
ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, format_hex_pretty_to(hex_buf, data.data(), data.size()));
ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type,
format_hex_pretty_to(hex_buf, this->recv_buf_.data(), length));
#endif
byte = this->read();
@@ -157,10 +160,10 @@ void HlkFm22xComponent::recv_command_() {
}
switch (response_type) {
case HlkFm22xResponseType::NOTE:
this->handle_note_(data);
this->handle_note_(this->recv_buf_.data(), length);
break;
case HlkFm22xResponseType::REPLY:
this->handle_reply_(data);
this->handle_reply_(this->recv_buf_.data(), length);
break;
default:
ESP_LOGW(TAG, "Unexpected response type: 0x%.2X", response_type);
@@ -168,11 +171,15 @@ void HlkFm22xComponent::recv_command_() {
}
}
void HlkFm22xComponent::handle_note_(const std::vector<uint8_t> &data) {
void HlkFm22xComponent::handle_note_(const uint8_t *data, size_t length) {
if (length < 1) {
ESP_LOGE(TAG, "Empty note data");
return;
}
switch (data[0]) {
case HlkFm22xNoteType::FACE_STATE:
if (data.size() < 17) {
ESP_LOGE(TAG, "Invalid face note data size: %u", data.size());
if (length < 17) {
ESP_LOGE(TAG, "Invalid face note data size: %zu", length);
break;
}
{
@@ -209,9 +216,13 @@ void HlkFm22xComponent::handle_note_(const std::vector<uint8_t> &data) {
}
}
void HlkFm22xComponent::handle_reply_(const std::vector<uint8_t> &data) {
void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) {
auto expected = this->active_command_;
this->active_command_ = HlkFm22xCommand::NONE;
if (length < 2) {
ESP_LOGE(TAG, "Reply too short: %zu bytes", length);
return;
}
if (data[0] != (uint8_t) expected) {
ESP_LOGE(TAG, "Unexpected response command. Expected: 0x%.2X, Received: 0x%.2X", expected, data[0]);
return;
@@ -238,16 +249,20 @@ void HlkFm22xComponent::handle_reply_(const std::vector<uint8_t> &data) {
}
switch (expected) {
case HlkFm22xCommand::VERIFY: {
if (length < 4 + HLK_FM22X_NAME_SIZE) {
ESP_LOGE(TAG, "VERIFY response too short: %zu bytes", length);
break;
}
int16_t face_id = ((int16_t) data[2] << 8) | data[3];
std::string name(data.begin() + 4, data.begin() + 36);
ESP_LOGD(TAG, "Face verified. ID: %d, name: %s", face_id, name.c_str());
const char *name_ptr = reinterpret_cast<const char *>(data + 4);
ESP_LOGD(TAG, "Face verified. ID: %d, name: %.*s", face_id, (int) HLK_FM22X_NAME_SIZE, name_ptr);
if (this->last_face_id_sensor_ != nullptr) {
this->last_face_id_sensor_->publish_state(face_id);
}
if (this->last_face_name_text_sensor_ != nullptr) {
this->last_face_name_text_sensor_->publish_state(name);
this->last_face_name_text_sensor_->publish_state(name_ptr, HLK_FM22X_NAME_SIZE);
}
this->face_scan_matched_callback_.call(face_id, name);
this->face_scan_matched_callback_.call(face_id, std::string(name_ptr, HLK_FM22X_NAME_SIZE));
break;
}
case HlkFm22xCommand::ENROLL: {
@@ -266,9 +281,8 @@ void HlkFm22xComponent::handle_reply_(const std::vector<uint8_t> &data) {
this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_VERSION); });
break;
case HlkFm22xCommand::GET_VERSION:
if (this->version_text_sensor_ != nullptr) {
std::string version(data.begin() + 2, data.end());
this->version_text_sensor_->publish_state(version);
if (this->version_text_sensor_ != nullptr && length > 2) {
this->version_text_sensor_->publish_state(reinterpret_cast<const char *>(data + 2), length - 2);
}
this->defer([this]() { this->get_face_count_(); });
break;

View File

@@ -7,12 +7,15 @@
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/components/uart/uart.h"
#include <array>
#include <utility>
#include <vector>
namespace esphome::hlk_fm22x {
static const uint16_t START_CODE = 0xEFAA;
static constexpr size_t HLK_FM22X_NAME_SIZE = 32;
// Maximum response payload: command(1) + result(1) + face_id(2) + name(32) = 36
static constexpr size_t HLK_FM22X_MAX_RESPONSE_SIZE = 36;
enum HlkFm22xCommand {
NONE = 0x00,
RESET = 0x10,
@@ -118,10 +121,11 @@ class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice {
void get_face_count_();
void send_command_(HlkFm22xCommand command, const uint8_t *data = nullptr, size_t size = 0);
void recv_command_();
void handle_note_(const std::vector<uint8_t> &data);
void handle_reply_(const std::vector<uint8_t> &data);
void handle_note_(const uint8_t *data, size_t length);
void handle_reply_(const uint8_t *data, size_t length);
void set_enrolling_(bool enrolling);
std::array<uint8_t, HLK_FM22X_MAX_RESPONSE_SIZE> recv_buf_;
HlkFm22xCommand active_command_ = HlkFm22xCommand::NONE;
uint16_t wait_cycles_ = 0;
sensor::Sensor *face_count_sensor_{nullptr};

View File

@@ -276,10 +276,10 @@ void LD2410Component::restart_and_read_all_info() {
void LD2410Component::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[MAX_LINE_LENGTH];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -311,10 +311,10 @@ void LD2412Component::restart_and_read_all_info() {
void LD2412Component::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[MAX_LINE_LENGTH];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -335,9 +335,10 @@ void LD2420Component::revert_config_action() {
void LD2420Component::loop() {
// If there is a active send command do not process it here, the send command call will handle it.
while (!this->cmd_active_ && this->available()) {
this->readline_(this->read(), this->buffer_data_, MAX_LINE_LENGTH);
if (this->cmd_active_) {
return;
}
this->read_batch_(this->buffer_data_);
}
void LD2420Component::update_radar_data(uint16_t const *gate_energy, uint8_t sample_number) {
@@ -539,6 +540,23 @@ void LD2420Component::handle_simple_mode_(const uint8_t *inbuf, int len) {
}
}
void LD2420Component::read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer) {
// Read all available bytes in batches to reduce UART call overhead.
size_t avail = this->available();
uint8_t buf[MAX_LINE_LENGTH];
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}
avail -= to_read;
for (size_t i = 0; i < to_read; i++) {
this->readline_(buf[i], buffer.data(), buffer.size());
}
}
}
void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
this->cmd_reply_.command = buffer[CMD_FRAME_COMMAND];
this->cmd_reply_.length = buffer[CMD_FRAME_DATA_LENGTH];

View File

@@ -4,6 +4,7 @@
#include "esphome/components/uart/uart.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include <span>
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
@@ -165,6 +166,7 @@ class LD2420Component : public Component, public uart::UARTDevice {
void handle_energy_mode_(uint8_t *buffer, int len);
void handle_ack_data_(uint8_t *buffer, int len);
void readline_(int rx_data, uint8_t *buffer, int len);
void read_batch_(std::span<uint8_t, MAX_LINE_LENGTH> buffer);
void set_calibration_(bool state) { this->calibration_ = state; };
bool get_calibration_() { return this->calibration_; };

View File

@@ -277,10 +277,10 @@ void LD2450Component::dump_config() {
void LD2450Component::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[MAX_LINE_LENGTH];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -36,8 +36,9 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch
#endif
// Fast path: main thread, no recursion (99.9% of all logs)
// Pass nullptr for thread_name since we already know this is the main task
if (is_main_task && !this->main_task_recursion_guard_) [[likely]] {
this->log_message_to_buffer_and_send_(this->main_task_recursion_guard_, level, tag, line, format, args);
this->log_message_to_buffer_and_send_(this->main_task_recursion_guard_, level, tag, line, format, args, nullptr);
return;
}
@@ -47,21 +48,23 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch
}
// Non-main thread handling (~0.1% of logs)
// Resolve thread name once and pass it through the logging chain.
// ESP32/LibreTiny: use TaskHandle_t overload to avoid redundant xTaskGetCurrentTaskHandle()
// (we already have the handle from the main task check above).
// Host: pass a stack buffer for pthread_getname_np to write into.
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
this->log_vprintf_non_main_thread_(level, tag, line, format, args, current_task);
const char *thread_name = get_thread_name_(current_task);
#else // USE_HOST
this->log_vprintf_non_main_thread_(level, tag, line, format, args);
char thread_name_buf[THREAD_NAME_BUF_SIZE];
const char *thread_name = this->get_thread_name_(thread_name_buf);
#endif
this->log_vprintf_non_main_thread_(level, tag, line, format, args, thread_name);
}
// Handles non-main thread logging only
// Kept separate from hot path to improve instruction cache performance
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
void Logger::log_vprintf_non_main_thread_(uint8_t level, const char *tag, int line, const char *format, va_list args,
TaskHandle_t current_task) {
#else // USE_HOST
void Logger::log_vprintf_non_main_thread_(uint8_t level, const char *tag, int line, const char *format, va_list args) {
#endif
const char *thread_name) {
// Check if already in recursion for this non-main thread/task
if (this->is_non_main_task_recursive_()) {
return;
@@ -73,12 +76,8 @@ void Logger::log_vprintf_non_main_thread_(uint8_t level, const char *tag, int li
bool message_sent = false;
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
// For non-main threads/tasks, queue the message for callbacks
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
message_sent =
this->log_buffer_->send_message_thread_safe(level, tag, static_cast<uint16_t>(line), current_task, format, args);
#else // USE_HOST
message_sent = this->log_buffer_->send_message_thread_safe(level, tag, static_cast<uint16_t>(line), format, args);
#endif
this->log_buffer_->send_message_thread_safe(level, tag, static_cast<uint16_t>(line), thread_name, format, args);
if (message_sent) {
// Enable logger loop to process the buffered message
// This is safe to call from any context including ISRs
@@ -101,19 +100,27 @@ void Logger::log_vprintf_non_main_thread_(uint8_t level, const char *tag, int li
#endif
char console_buffer[MAX_CONSOLE_LOG_MSG_SIZE]; // MUST be stack allocated for thread safety
LogBuffer buf{console_buffer, MAX_CONSOLE_LOG_MSG_SIZE};
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, buf);
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, buf, thread_name);
this->write_to_console_(buf);
}
// RAII guard automatically resets on return
}
#else
// Implementation for all other platforms (single-task, no threading)
// Implementation for single-task platforms (ESP8266, RP2040, Zephyr)
// TODO: Zephyr may have multiple threads (work queues, etc.) but uses this single-task path.
// Logging calls are NOT thread-safe: global_recursion_guard_ is a plain bool and tx_buffer_ has no locking.
// Not a problem in practice yet since Zephyr has no API support (logs are console-only).
void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args) { // NOLINT
if (level > this->level_for(tag) || global_recursion_guard_)
return;
this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args);
#ifdef USE_ZEPHYR
char tmp[MAX_POINTER_REPRESENTATION];
this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args,
this->get_thread_name_(tmp));
#else // Other single-task platforms don't have thread names, so pass nullptr
this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args, nullptr);
#endif
}
#endif // USE_ESP32 / USE_HOST / USE_LIBRETINY
@@ -129,7 +136,7 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas
if (level > this->level_for(tag) || global_recursion_guard_)
return;
this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args);
this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args, nullptr);
}
#endif // USE_STORE_LOG_STR_IN_FLASH

View File

@@ -2,6 +2,7 @@
#include <cstdarg>
#include <map>
#include <span>
#include <type_traits>
#if defined(USE_ESP32) || defined(USE_HOST)
#include <pthread.h>
@@ -124,6 +125,10 @@ static constexpr uint16_t MAX_HEADER_SIZE = 128;
// "0x" + 2 hex digits per byte + '\0'
static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1;
// Stack buffer size for retrieving thread/task names from the OS
// macOS allows up to 64 bytes, Linux up to 16
static constexpr size_t THREAD_NAME_BUF_SIZE = 64;
// Buffer wrapper for log formatting functions
struct LogBuffer {
char *data;
@@ -408,34 +413,24 @@ class Logger : public Component {
#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY)
// Handles non-main thread logging only (~0.1% of calls)
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
// ESP32/LibreTiny: Pass task handle to avoid calling xTaskGetCurrentTaskHandle() twice
// thread_name is resolved by the caller from the task handle, avoiding redundant lookups
void log_vprintf_non_main_thread_(uint8_t level, const char *tag, int line, const char *format, va_list args,
TaskHandle_t current_task);
#else // USE_HOST
// Host: No task handle parameter needed (not used in send_message_thread_safe)
void log_vprintf_non_main_thread_(uint8_t level, const char *tag, int line, const char *format, va_list args);
#endif
const char *thread_name);
#endif
void process_messages_();
void write_msg_(const char *msg, uint16_t len);
// Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator
// thread_name: name of the calling thread/task, or nullptr for main task (callers already know which task they're on)
inline void HOT format_log_to_buffer_with_terminator_(uint8_t level, const char *tag, int line, const char *format,
va_list args, LogBuffer &buf) {
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_HOST)
buf.write_header(level, tag, line, this->get_thread_name_());
#elif defined(USE_ZEPHYR)
char tmp[MAX_POINTER_REPRESENTATION];
buf.write_header(level, tag, line, this->get_thread_name_(tmp));
#else
buf.write_header(level, tag, line, nullptr);
#endif
va_list args, LogBuffer &buf, const char *thread_name) {
buf.write_header(level, tag, line, thread_name);
buf.format_body(format, args);
}
#ifdef USE_STORE_LOG_STR_IN_FLASH
// Format a log message with flash string format and write it to a buffer with header, footer, and null terminator
// ESP8266-only (single-task), thread_name is always nullptr
inline void HOT format_log_to_buffer_with_terminator_P_(uint8_t level, const char *tag, int line,
const __FlashStringHelper *format, va_list args,
LogBuffer &buf) {
@@ -466,9 +461,10 @@ class Logger : public Component {
// Helper to format and send a log message to both console and listeners
// Template handles both const char* (RAM) and __FlashStringHelper* (flash) format strings
// thread_name: name of the calling thread/task, or nullptr for main task
template<typename FormatType>
inline void HOT log_message_to_buffer_and_send_(bool &recursion_guard, uint8_t level, const char *tag, int line,
FormatType format, va_list args) {
FormatType format, va_list args, const char *thread_name) {
RecursionGuard guard(recursion_guard);
LogBuffer buf{this->tx_buffer_, this->tx_buffer_size_};
#ifdef USE_STORE_LOG_STR_IN_FLASH
@@ -477,7 +473,7 @@ class Logger : public Component {
} else
#endif
{
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, buf);
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, buf, thread_name);
}
this->notify_listeners_(level, tag, buf);
this->write_log_buffer_to_console_(buf);
@@ -565,37 +561,57 @@ class Logger : public Component {
bool global_recursion_guard_{false}; // Simple global recursion guard for single-task platforms
#endif
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
const char *HOT get_thread_name_(
#ifdef USE_ZEPHYR
char *buff
// --- get_thread_name_ overloads (per-platform) ---
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
// Primary overload - takes a task handle directly to avoid redundant xTaskGetCurrentTaskHandle() calls
// when the caller already has the handle (e.g. from the main task check in log_vprintf_)
const char *get_thread_name_(TaskHandle_t task) {
if (task == this->main_task_) {
return nullptr; // Main task
}
#if defined(USE_ESP32)
return pcTaskGetName(task);
#elif defined(USE_LIBRETINY)
return pcTaskGetTaskName(task);
#endif
) {
#ifdef USE_ZEPHYR
}
// Convenience overload - gets the current task handle and delegates
const char *HOT get_thread_name_() { return this->get_thread_name_(xTaskGetCurrentTaskHandle()); }
#elif defined(USE_HOST)
// Takes a caller-provided buffer for the thread name (stack-allocated for thread safety)
const char *HOT get_thread_name_(std::span<char> buff) {
pthread_t current_thread = pthread_self();
if (pthread_equal(current_thread, main_thread_)) {
return nullptr; // Main thread
}
// For non-main threads, get the thread name into the caller-provided buffer
if (pthread_getname_np(current_thread, buff.data(), buff.size()) == 0) {
return buff.data();
}
return nullptr;
}
#elif defined(USE_ZEPHYR)
const char *HOT get_thread_name_(std::span<char> buff) {
k_tid_t current_task = k_current_get();
#else
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
#endif
if (current_task == main_task_) {
return nullptr; // Main task
} else {
#if defined(USE_ESP32)
return pcTaskGetName(current_task);
#elif defined(USE_LIBRETINY)
return pcTaskGetTaskName(current_task);
#elif defined(USE_ZEPHYR)
const char *name = k_thread_name_get(current_task);
if (name) {
// zephyr print task names only if debug component is present
return name;
}
std::snprintf(buff, MAX_POINTER_REPRESENTATION, "%p", current_task);
return buff;
#endif
}
const char *name = k_thread_name_get(current_task);
if (name) {
// zephyr print task names only if debug component is present
return name;
}
std::snprintf(buff.data(), buff.size(), "%p", current_task);
return buff.data();
}
#endif
// --- Non-main task recursion guards (per-platform) ---
#if defined(USE_ESP32) || defined(USE_HOST)
// RAII guard for non-main task recursion using pthread TLS
class NonMainTaskRecursionGuard {
@@ -635,22 +651,6 @@ class Logger : public Component {
inline RecursionGuard make_non_main_task_guard_() { return RecursionGuard(non_main_task_recursion_guard_); }
#endif
#ifdef USE_HOST
const char *HOT get_thread_name_() {
pthread_t current_thread = pthread_self();
if (pthread_equal(current_thread, main_thread_)) {
return nullptr; // Main thread
}
// For non-main threads, return the thread name
// We store it in thread-local storage to avoid allocation
static thread_local char thread_name_buf[32];
if (pthread_getname_np(current_thread, thread_name_buf, sizeof(thread_name_buf)) == 0) {
return thread_name_buf;
}
return nullptr;
}
#endif
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
// Disable loop when task buffer is empty (with USB CDC check on ESP32)
inline void disable_loop_when_buffer_empty_() {

View File

@@ -59,7 +59,7 @@ void TaskLogBuffer::release_message_main_loop(void *token) {
last_processed_counter_ = message_counter_.load(std::memory_order_relaxed);
}
bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, TaskHandle_t task_handle,
bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *thread_name,
const char *format, va_list args) {
// First, calculate the exact length needed using a null buffer (no actual writing)
va_list args_copy;
@@ -95,7 +95,6 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin
// Store the thread name now instead of waiting until main loop processing
// This avoids crashes if the task completes or is deleted between when this message
// is enqueued and when it's processed by the main loop
const char *thread_name = pcTaskGetName(task_handle);
if (thread_name != nullptr) {
strncpy(msg->thread_name, thread_name, sizeof(msg->thread_name) - 1);
msg->thread_name[sizeof(msg->thread_name) - 1] = '\0'; // Ensure null termination

View File

@@ -58,7 +58,7 @@ class TaskLogBuffer {
void release_message_main_loop(void *token);
// Thread-safe - send a message to the ring buffer from any thread
bool send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, TaskHandle_t task_handle,
bool send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *thread_name,
const char *format, va_list args);
// Check if there are messages ready to be processed using an atomic counter for performance

View File

@@ -70,8 +70,8 @@ void TaskLogBufferHost::commit_write_slot_(int slot_index) {
}
}
bool TaskLogBufferHost::send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *format,
va_list args) {
bool TaskLogBufferHost::send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *thread_name,
const char *format, va_list args) {
// Acquire a slot
int slot_index = this->acquire_write_slot_();
if (slot_index < 0) {
@@ -85,11 +85,9 @@ bool TaskLogBufferHost::send_message_thread_safe(uint8_t level, const char *tag,
msg.tag = tag;
msg.line = line;
// Get thread name using pthread
char thread_name_buf[LogMessage::MAX_THREAD_NAME_SIZE];
// pthread_getname_np works the same on Linux and macOS
if (pthread_getname_np(pthread_self(), thread_name_buf, sizeof(thread_name_buf)) == 0) {
strncpy(msg.thread_name, thread_name_buf, sizeof(msg.thread_name) - 1);
// Store the thread name now to avoid crashes if thread exits before processing
if (thread_name != nullptr) {
strncpy(msg.thread_name, thread_name, sizeof(msg.thread_name) - 1);
msg.thread_name[sizeof(msg.thread_name) - 1] = '\0';
} else {
msg.thread_name[0] = '\0';

View File

@@ -86,7 +86,8 @@ class TaskLogBufferHost {
// Thread-safe - send a message to the buffer from any thread
// Returns true if message was queued, false if buffer is full
bool send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *format, va_list args);
bool send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *thread_name,
const char *format, va_list args);
// Check if there are messages ready to be processed
inline bool HOT has_messages() const {

View File

@@ -101,7 +101,7 @@ void TaskLogBufferLibreTiny::release_message_main_loop() {
}
bool TaskLogBufferLibreTiny::send_message_thread_safe(uint8_t level, const char *tag, uint16_t line,
TaskHandle_t task_handle, const char *format, va_list args) {
const char *thread_name, const char *format, va_list args) {
// First, calculate the exact length needed using a null buffer (no actual writing)
va_list args_copy;
va_copy(args_copy, args);
@@ -162,7 +162,6 @@ bool TaskLogBufferLibreTiny::send_message_thread_safe(uint8_t level, const char
msg->line = line;
// Store the thread name now to avoid crashes if task is deleted before processing
const char *thread_name = pcTaskGetTaskName(task_handle);
if (thread_name != nullptr) {
strncpy(msg->thread_name, thread_name, sizeof(msg->thread_name) - 1);
msg->thread_name[sizeof(msg->thread_name) - 1] = '\0';

View File

@@ -70,7 +70,7 @@ class TaskLogBufferLibreTiny {
void release_message_main_loop();
// Thread-safe - send a message to the buffer from any thread
bool send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, TaskHandle_t task_handle,
bool send_message_thread_safe(uint8_t level, const char *tag, uint16_t line, const char *thread_name,
const char *format, va_list args);
// Fast check using volatile counter - no lock needed

View File

@@ -120,3 +120,101 @@ DriverChip(
(0xB2, 0x10),
],
)
DriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
height=800,
width=800,
hsync_back_porch=20,
hsync_pulse_width=20,
hsync_front_porch=40,
vsync_back_porch=12,
vsync_pulse_width=4,
vsync_front_porch=24,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
(0xE1, 0x93), (0xE2, 0x65), (0xE3, 0xF8),
(0x80, 0x01), # Select number of lanes (2)
(0xE0, 0x01), # select page 1
(0x00, 0x00), (0x01, 0x41), (0x03, 0x10), (0x04, 0x44), (0x17, 0x00), (0x18, 0xD0), (0x19, 0x00), (0x1A, 0x00),
(0x1B, 0xD0), (0x1C, 0x00), (0x24, 0xFE), (0x35, 0x26), (0x37, 0x09), (0x38, 0x04), (0x39, 0x08), (0x3A, 0x0A),
(0x3C, 0x78), (0x3D, 0xFF), (0x3E, 0xFF), (0x3F, 0xFF), (0x40, 0x00), (0x41, 0x64), (0x42, 0xC7), (0x43, 0x18),
(0x44, 0x0B), (0x45, 0x14), (0x55, 0x02), (0x57, 0x49), (0x59, 0x0A), (0x5A, 0x1B), (0x5B, 0x19), (0x5D, 0x7F),
(0x5E, 0x56), (0x5F, 0x43), (0x60, 0x37), (0x61, 0x33), (0x62, 0x25), (0x63, 0x2A), (0x64, 0x16), (0x65, 0x30),
(0x66, 0x2F), (0x67, 0x32), (0x68, 0x53), (0x69, 0x43), (0x6A, 0x4C), (0x6B, 0x40), (0x6C, 0x3D), (0x6D, 0x31),
(0x6E, 0x20), (0x6F, 0x0F), (0x70, 0x7F), (0x71, 0x56), (0x72, 0x43), (0x73, 0x37), (0x74, 0x33), (0x75, 0x25),
(0x76, 0x2A), (0x77, 0x16), (0x78, 0x30), (0x79, 0x2F), (0x7A, 0x32), (0x7B, 0x53), (0x7C, 0x43), (0x7D, 0x4C),
(0x7E, 0x40), (0x7F, 0x3D), (0x80, 0x31), (0x81, 0x20), (0x82, 0x0F),
(0xE0, 0x02), # select page 2
(0x00, 0x5F), (0x01, 0x5F), (0x02, 0x5E), (0x03, 0x5E), (0x04, 0x50), (0x05, 0x48), (0x06, 0x48), (0x07, 0x4A),
(0x08, 0x4A), (0x09, 0x44), (0x0A, 0x44), (0x0B, 0x46), (0x0C, 0x46), (0x0D, 0x5F), (0x0E, 0x5F), (0x0F, 0x57),
(0x10, 0x57), (0x11, 0x77), (0x12, 0x77), (0x13, 0x40), (0x14, 0x42), (0x15, 0x5F), (0x16, 0x5F), (0x17, 0x5F),
(0x18, 0x5E), (0x19, 0x5E), (0x1A, 0x50), (0x1B, 0x49), (0x1C, 0x49), (0x1D, 0x4B), (0x1E, 0x4B), (0x1F, 0x45),
(0x20, 0x45), (0x21, 0x47), (0x22, 0x47), (0x23, 0x5F), (0x24, 0x5F), (0x25, 0x57), (0x26, 0x57), (0x27, 0x77),
(0x28, 0x77), (0x29, 0x41), (0x2A, 0x43), (0x2B, 0x5F), (0x2C, 0x1E), (0x2D, 0x1E), (0x2E, 0x1F), (0x2F, 0x1F),
(0x30, 0x10), (0x31, 0x07), (0x32, 0x07), (0x33, 0x05), (0x34, 0x05), (0x35, 0x0B), (0x36, 0x0B), (0x37, 0x09),
(0x38, 0x09), (0x39, 0x1F), (0x3A, 0x1F), (0x3B, 0x17), (0x3C, 0x17), (0x3D, 0x17), (0x3E, 0x17), (0x3F, 0x03),
(0x40, 0x01), (0x41, 0x1F), (0x42, 0x1E), (0x43, 0x1E), (0x44, 0x1F), (0x45, 0x1F), (0x46, 0x10), (0x47, 0x06),
(0x48, 0x06), (0x49, 0x04), (0x4A, 0x04), (0x4B, 0x0A), (0x4C, 0x0A), (0x4D, 0x08), (0x4E, 0x08), (0x4F, 0x1F),
(0x50, 0x1F), (0x51, 0x17), (0x52, 0x17), (0x53, 0x17), (0x54, 0x17), (0x55, 0x02), (0x56, 0x00), (0x57, 0x1F),
(0xE0, 0x02), # select page 2
(0x58, 0x40), (0x59, 0x00), (0x5A, 0x00), (0x5B, 0x30), (0x5C, 0x01), (0x5D, 0x30), (0x5E, 0x01), (0x5F, 0x02),
(0x60, 0x30), (0x61, 0x03), (0x62, 0x04), (0x63, 0x04), (0x64, 0xA6), (0x65, 0x43), (0x66, 0x30), (0x67, 0x73),
(0x68, 0x05), (0x69, 0x04), (0x6A, 0x7F), (0x6B, 0x08), (0x6C, 0x00), (0x6D, 0x04), (0x6E, 0x04), (0x6F, 0x88),
(0x75, 0xD9), (0x76, 0x00), (0x77, 0x33), (0x78, 0x43),
(0xE0, 0x00), # select userpage
],
)
DriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
height=720,
width=720,
hsync_back_porch=20,
hsync_pulse_width=20,
hsync_front_porch=40,
vsync_back_porch=12,
vsync_pulse_width=4,
vsync_front_porch=24,
pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0xE0, 0x00), # select userpage
(0xE1, 0x93), (0xE2, 0x65), (0xE3, 0xF8),
(0x80, 0x01), # Select number of lanes (2)
(0xE0, 0x01), # select page 1
(0x00, 0x00), (0x01, 0x41), (0x03, 0x10), (0x04, 0x44), (0x17, 0x00), (0x18, 0xD0), (0x19, 0x00), (0x1A, 0x00),
(0x1B, 0xD0), (0x1C, 0x00), (0x24, 0xFE), (0x35, 0x26), (0x37, 0x09), (0x38, 0x04), (0x39, 0x08), (0x3A, 0x0A),
(0x3C, 0x78), (0x3D, 0xFF), (0x3E, 0xFF), (0x3F, 0xFF), (0x40, 0x04), (0x41, 0x64), (0x42, 0xC7), (0x43, 0x18),
(0x44, 0x0B), (0x45, 0x14), (0x55, 0x02), (0x57, 0x49), (0x59, 0x0A), (0x5A, 0x1B), (0x5B, 0x19), (0x5D, 0x7F),
(0x5E, 0x56), (0x5F, 0x43), (0x60, 0x37), (0x61, 0x33), (0x62, 0x25), (0x63, 0x2A), (0x64, 0x16), (0x65, 0x30),
(0x66, 0x2F), (0x67, 0x32), (0x68, 0x53), (0x69, 0x43), (0x6A, 0x4C), (0x6B, 0x40), (0x6C, 0x3D), (0x6D, 0x31),
(0x6E, 0x20), (0x6F, 0x0F), (0x70, 0x7F), (0x71, 0x56), (0x72, 0x43), (0x73, 0x37), (0x74, 0x33), (0x75, 0x25),
(0x76, 0x2A), (0x77, 0x16), (0x78, 0x30), (0x79, 0x2F), (0x7A, 0x32), (0x7B, 0x53), (0x7C, 0x43), (0x7D, 0x4C),
(0x7E, 0x40), (0x7F, 0x3D), (0x80, 0x31), (0x81, 0x20), (0x82, 0x0F),
(0xE0, 0x02), # select page 2
(0x00, 0x5F), (0x01, 0x5F), (0x02, 0x5E), (0x03, 0x5E), (0x04, 0x50), (0x05, 0x48), (0x06, 0x48), (0x07, 0x4A),
(0x08, 0x4A), (0x09, 0x44), (0x0A, 0x44), (0x0B, 0x46), (0x0C, 0x46), (0x0D, 0x5F), (0x0E, 0x5F), (0x0F, 0x57),
(0x10, 0x57), (0x11, 0x77), (0x12, 0x77), (0x13, 0x40), (0x14, 0x42), (0x15, 0x5F), (0x16, 0x5F), (0x17, 0x5F),
(0x18, 0x5E), (0x19, 0x5E), (0x1A, 0x50), (0x1B, 0x49), (0x1C, 0x49), (0x1D, 0x4B), (0x1E, 0x4B), (0x1F, 0x45),
(0x20, 0x45), (0x21, 0x47), (0x22, 0x47), (0x23, 0x5F), (0x24, 0x5F), (0x25, 0x57), (0x26, 0x57), (0x27, 0x77),
(0x28, 0x77), (0x29, 0x41), (0x2A, 0x43), (0x2B, 0x5F), (0x2C, 0x1E), (0x2D, 0x1E), (0x2E, 0x1F), (0x2F, 0x1F),
(0x30, 0x10), (0x31, 0x07), (0x32, 0x07), (0x33, 0x05), (0x34, 0x05), (0x35, 0x0B), (0x36, 0x0B), (0x37, 0x09),
(0x38, 0x09), (0x39, 0x1F), (0x3A, 0x1F), (0x3B, 0x17), (0x3C, 0x17), (0x3D, 0x17), (0x3E, 0x17), (0x3F, 0x03),
(0x40, 0x01), (0x41, 0x1F), (0x42, 0x1E), (0x43, 0x1E), (0x44, 0x1F), (0x45, 0x1F), (0x46, 0x10), (0x47, 0x06),
(0x48, 0x06), (0x49, 0x04), (0x4A, 0x04), (0x4B, 0x0A), (0x4C, 0x0A), (0x4D, 0x08), (0x4E, 0x08), (0x4F, 0x1F),
(0x50, 0x1F), (0x51, 0x17), (0x52, 0x17), (0x53, 0x17), (0x54, 0x17), (0x55, 0x02), (0x56, 0x00), (0x57, 0x1F),
(0xE0, 0x02), # select page 2
(0x58, 0x40), (0x59, 0x00), (0x5A, 0x00), (0x5B, 0x30), (0x5C, 0x01), (0x5D, 0x30), (0x5E, 0x01), (0x5F, 0x02),
(0x60, 0x30), (0x61, 0x03), (0x62, 0x04), (0x63, 0x04), (0x64, 0xA6), (0x65, 0x43), (0x66, 0x30), (0x67, 0x73),
(0x68, 0x05), (0x69, 0x04), (0x6A, 0x7F), (0x6B, 0x08), (0x6C, 0x00), (0x6D, 0x04), (0x6E, 0x04), (0x6F, 0x88),
(0x75, 0xD9), (0x76, 0x00), (0x77, 0x33), (0x78, 0x43),
(0xE0, 0x00), # select userpage
]
)

View File

@@ -11,7 +11,7 @@ from esphome.components.const import (
CONF_DRAW_ROUNDING,
)
from esphome.components.display import CONF_SHOW_TEST_CARD
from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant
from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S3, only_on_variant
from esphome.components.mipi import (
COLOR_ORDERS,
CONF_DE_PIN,
@@ -225,7 +225,7 @@ def _config_schema(config):
return cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32S3]),
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config)

View File

@@ -1,4 +1,4 @@
#ifdef USE_ESP32_VARIANT_ESP32S3
#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)
#include "mipi_rgb.h"
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
@@ -401,4 +401,4 @@ void MipiRgb::dump_config() {
} // namespace mipi_rgb
} // namespace esphome
#endif // USE_ESP32_VARIANT_ESP32S3
#endif // defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)

View File

@@ -1,6 +1,6 @@
#pragma once
#ifdef USE_ESP32_VARIANT_ESP32S3
#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)
#include "esphome/core/gpio.h"
#include "esphome/components/display/display.h"
#include "esp_lcd_panel_ops.h"
@@ -28,7 +28,7 @@ class MipiRgb : public display::Display {
void setup() override;
void loop() override;
void update() override;
void fill(Color color);
void fill(Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset,
@@ -115,7 +115,7 @@ class MipiRgbSpi : public MipiRgb,
void write_command_(uint8_t value);
void write_data_(uint8_t value);
void write_init_sequence_();
void dump_config();
void dump_config() override;
GPIOPin *dc_pin_{nullptr};
std::vector<uint8_t> init_sequence_;

View File

@@ -20,10 +20,10 @@ void Modbus::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -398,10 +398,10 @@ bool Nextion::remove_from_q_(bool report_empty) {
void Nextion::process_serial_() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -14,9 +14,9 @@ void Pipsolar::setup() {
void Pipsolar::empty_uart_buffer_() {
uint8_t buf[64];
int avail;
size_t avail;
while ((avail = this->available()) > 0) {
if (!this->read_array(buf, std::min(static_cast<size_t>(avail), sizeof(buf)))) {
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
break;
}
}
@@ -97,10 +97,10 @@ void Pipsolar::loop() {
}
if (this->state_ == STATE_COMMAND || this->state_ == STATE_POLL) {
int avail = this->available();
size_t avail = this->available();
while (avail > 0) {
uint8_t buf[64];
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -56,17 +56,23 @@ void PylontechComponent::setup() {
void PylontechComponent::update() { this->write_str("pwr\n"); }
void PylontechComponent::loop() {
if (this->available() > 0) {
size_t avail = this->available();
if (avail > 0) {
// pylontech sends a lot of data very suddenly
// we need to quickly put it all into our own buffer, otherwise the uart's buffer will overflow
uint8_t data;
int recv = 0;
while (this->available() > 0) {
if (this->read_byte(&data)) {
buffer_[buffer_index_write_] += (char) data;
recv++;
if (buffer_[buffer_index_write_].back() == static_cast<char>(ASCII_LF) ||
buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) {
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}
avail -= to_read;
recv += to_read;
for (size_t i = 0; i < to_read; i++) {
buffer_[buffer_index_write_] += (char) buf[i];
if (buf[i] == ASCII_LF || buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) {
// complete line received
buffer_index_write_ = (buffer_index_write_ + 1) % NUM_BUFFERS;
}

View File

@@ -82,10 +82,10 @@ void RD03DComponent::dump_config() {
void RD03DComponent::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -136,10 +136,10 @@ void RFBridgeComponent::loop() {
this->last_bridge_byte_ = now;
}
int avail = this->available();
size_t avail = this->available();
while (avail > 0) {
uint8_t buf[64];
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -107,10 +107,10 @@ void MR24HPC1Component::update_() {
// main loop
void MR24HPC1Component::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -31,10 +31,10 @@ void MR60BHA2Component::dump_config() {
// main loop
void MR60BHA2Component::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -50,10 +50,10 @@ void MR60FDA2Component::setup() {
// main loop
void MR60FDA2Component::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -251,7 +251,7 @@ void Tormatic::stop_at_target_() {
// Read a GateStatus from the unit. The unit only sends messages in response to
// status requests or commands, so a message needs to be sent first.
optional<GateStatus> Tormatic::read_gate_status_() {
if (this->available() < static_cast<int>(sizeof(MessageHeader))) {
if (this->available() < sizeof(MessageHeader)) {
return {};
}

View File

@@ -32,10 +32,10 @@ void Tuya::setup() {
void Tuya::loop() {
// Read all available bytes in batches to reduce UART call overhead.
int avail = this->available();
size_t avail = this->available();
uint8_t buf[64];
while (avail > 0) {
size_t to_read = std::min(static_cast<size_t>(avail), sizeof(buf));
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) {
break;
}

View File

@@ -3,12 +3,16 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <cinttypes>
namespace esphome::uart {
static const char *const TAG = "uart";
// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD
PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN");
void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity,
uint8_t data_bits) {
if (this->parent_->get_baud_rate() != baud_rate) {
@@ -30,16 +34,7 @@ void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UART
}
const LogString *parity_to_str(UARTParityOptions parity) {
switch (parity) {
case UART_CONFIG_PARITY_NONE:
return LOG_STR("NONE");
case UART_CONFIG_PARITY_EVEN:
return LOG_STR("EVEN");
case UART_CONFIG_PARITY_ODD:
return LOG_STR("ODD");
default:
return LOG_STR("UNKNOWN");
}
return UARTParityStrings::get_log_str(static_cast<uint8_t>(parity), UARTParityStrings::LAST_INDEX);
}
} // namespace esphome::uart

View File

@@ -43,7 +43,7 @@ class UARTDevice {
return res;
}
int available() { return this->parent_->available(); }
size_t available() { return this->parent_->available(); }
void flush() { this->parent_->flush(); }

View File

@@ -5,13 +5,13 @@ namespace esphome::uart {
static const char *const TAG = "uart";
bool UARTComponent::check_read_timeout_(size_t len) {
if (this->available() >= int(len))
if (this->available() >= len)
return true;
uint32_t start_time = millis();
while (this->available() < int(len)) {
while (this->available() < len) {
if (millis() - start_time > 100) {
ESP_LOGE(TAG, "Reading from UART timed out at byte %u!", this->available());
ESP_LOGE(TAG, "Reading from UART timed out at byte %zu!", this->available());
return false;
}
yield();

View File

@@ -69,7 +69,7 @@ class UARTComponent {
// Pure virtual method to return the number of bytes available for reading.
// @return Number of available bytes.
virtual int available() = 0;
virtual size_t available() = 0;
// Pure virtual method to block until all bytes have been written to the UART bus.
virtual void flush() = 0;

View File

@@ -206,7 +206,7 @@ bool ESP8266UartComponent::read_array(uint8_t *data, size_t len) {
#endif
return true;
}
int ESP8266UartComponent::available() {
size_t ESP8266UartComponent::available() {
if (this->hw_serial_ != nullptr) {
return this->hw_serial_->available();
} else {
@@ -329,11 +329,14 @@ uint8_t ESP8266SoftwareSerial::peek_byte() {
void ESP8266SoftwareSerial::flush() {
// Flush is a NO-OP with software serial, all bytes are written immediately.
}
int ESP8266SoftwareSerial::available() {
int avail = int(this->rx_in_pos_) - int(this->rx_out_pos_);
if (avail < 0)
return avail + this->rx_buffer_size_;
return avail;
size_t ESP8266SoftwareSerial::available() {
// Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR.
// When in >= out, data is contiguous: [out..in).
// When in < out, data wraps: [out..buf_size) + [0..in).
size_t in = this->rx_in_pos_;
if (in >= this->rx_out_pos_)
return in - this->rx_out_pos_;
return this->rx_buffer_size_ - this->rx_out_pos_ + in;
}
} // namespace esphome::uart

View File

@@ -23,7 +23,7 @@ class ESP8266SoftwareSerial {
void write_byte(uint8_t data);
int available();
size_t available();
protected:
static void gpio_intr(ESP8266SoftwareSerial *arg);
@@ -57,7 +57,7 @@ class ESP8266UartComponent : public UARTComponent, public Component {
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
int available() override;
size_t available() override;
void flush() override;
uint32_t get_config();

View File

@@ -338,7 +338,7 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
return read_len == (int32_t) length_to_read;
}
int IDFUARTComponent::available() {
size_t IDFUARTComponent::available() {
size_t available = 0;
esp_err_t err;

View File

@@ -22,7 +22,7 @@ class IDFUARTComponent : public UARTComponent, public Component {
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
int available() override;
size_t available() override;
void flush() override;
uint8_t get_hw_serial_number() { return this->uart_num_; }

View File

@@ -265,7 +265,7 @@ bool HostUartComponent::read_array(uint8_t *data, size_t len) {
return true;
}
int HostUartComponent::available() {
size_t HostUartComponent::available() {
if (this->file_descriptor_ == -1) {
return 0;
}
@@ -275,9 +275,10 @@ int HostUartComponent::available() {
this->update_error_(strerror(errno));
return 0;
}
size_t result = available;
if (this->has_peek_)
available++;
return available;
result++;
return result;
};
void HostUartComponent::flush() {

View File

@@ -17,7 +17,7 @@ class HostUartComponent : public UARTComponent, public Component {
void write_array(const uint8_t *data, size_t len) override;
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
int available() override;
size_t available() override;
void flush() override;
void set_name(std::string port_name) { port_name_ = port_name; };

View File

@@ -169,7 +169,7 @@ bool LibreTinyUARTComponent::read_array(uint8_t *data, size_t len) {
return true;
}
int LibreTinyUARTComponent::available() { return this->serial_->available(); }
size_t LibreTinyUARTComponent::available() { return this->serial_->available(); }
void LibreTinyUARTComponent::flush() {
ESP_LOGVV(TAG, " Flushing");
this->serial_->flush();

View File

@@ -21,7 +21,7 @@ class LibreTinyUARTComponent : public UARTComponent, public Component {
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
int available() override;
size_t available() override;
void flush() override;
uint16_t get_config();

View File

@@ -186,7 +186,7 @@ bool RP2040UartComponent::read_array(uint8_t *data, size_t len) {
#endif
return true;
}
int RP2040UartComponent::available() { return this->serial_->available(); }
size_t RP2040UartComponent::available() { return this->serial_->available(); }
void RP2040UartComponent::flush() {
ESP_LOGVV(TAG, " Flushing");
this->serial_->flush();

View File

@@ -24,7 +24,7 @@ class RP2040UartComponent : public UARTComponent, public Component {
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
int available() override;
size_t available() override;
void flush() override;
uint16_t get_config();

View File

@@ -81,7 +81,7 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented<USBCDCACMC
void write_array(const uint8_t *data, size_t len) override;
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
int available() override;
size_t available() override;
void flush() override;
protected:

View File

@@ -318,12 +318,12 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) {
return bytes_read == original_len;
}
int USBCDCACMInstance::available() {
size_t USBCDCACMInstance::available() {
UBaseType_t waiting = 0;
if (this->usb_rx_ringbuf_ != nullptr) {
vRingbufferGetInfo(this->usb_rx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting);
}
return static_cast<int>(waiting) + (this->has_peek_ ? 1 : 0);
return waiting + (this->has_peek_ ? 1 : 0);
}
void USBCDCACMInstance::flush() {

View File

@@ -97,7 +97,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented<USBUartCompon
bool peek_byte(uint8_t *data) override;
;
bool read_array(uint8_t *data, size_t len) override;
int available() override { return static_cast<int>(this->input_buffer_.get_available()); }
size_t available() override { return this->input_buffer_.get_available(); }
void flush() override {}
void check_logger_conflict() override {}
void set_parity(UARTParityOptions parity) { this->parity_ = parity; }

View File

@@ -371,7 +371,12 @@ async def to_code(config):
if on_timer_tick := config.get(CONF_ON_TIMER_TICK):
await automation.build_automation(
var.get_timer_tick_trigger(),
[(cg.std_vector.template(Timer), "timers")],
[
(
cg.std_vector.template(Timer).operator("const").operator("ref"),
"timers",
)
],
on_timer_tick,
)
has_timers = True

View File

@@ -859,35 +859,43 @@ void VoiceAssistant::on_audio(const api::VoiceAssistantAudio &msg) {
}
void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse &msg) {
Timer timer = {
.id = msg.timer_id,
.name = msg.name,
.total_seconds = msg.total_seconds,
.seconds_left = msg.seconds_left,
.is_active = msg.is_active,
};
this->timers_[timer.id] = timer;
// Find existing timer or add a new one
auto it = this->timers_.begin();
for (; it != this->timers_.end(); ++it) {
if (it->id == msg.timer_id)
break;
}
if (it == this->timers_.end()) {
this->timers_.push_back({});
it = this->timers_.end() - 1;
}
it->id = msg.timer_id;
it->name = msg.name;
it->total_seconds = msg.total_seconds;
it->seconds_left = msg.seconds_left;
it->is_active = msg.is_active;
char timer_buf[Timer::TO_STR_BUFFER_SIZE];
ESP_LOGD(TAG,
"Timer Event\n"
" Type: %" PRId32 "\n"
" %s",
msg.event_type, timer.to_str(timer_buf));
msg.event_type, it->to_str(timer_buf));
switch (msg.event_type) {
case api::enums::VOICE_ASSISTANT_TIMER_STARTED:
this->timer_started_trigger_.trigger(timer);
this->timer_started_trigger_.trigger(*it);
break;
case api::enums::VOICE_ASSISTANT_TIMER_UPDATED:
this->timer_updated_trigger_.trigger(timer);
this->timer_updated_trigger_.trigger(*it);
break;
case api::enums::VOICE_ASSISTANT_TIMER_CANCELLED:
this->timer_cancelled_trigger_.trigger(timer);
this->timers_.erase(timer.id);
this->timer_cancelled_trigger_.trigger(*it);
this->timers_.erase(it);
break;
case api::enums::VOICE_ASSISTANT_TIMER_FINISHED:
this->timer_finished_trigger_.trigger(timer);
this->timers_.erase(timer.id);
this->timer_finished_trigger_.trigger(*it);
this->timers_.erase(it);
break;
}
@@ -901,16 +909,12 @@ void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse
}
void VoiceAssistant::timer_tick_() {
std::vector<Timer> res;
res.reserve(this->timers_.size());
for (auto &pair : this->timers_) {
auto &timer = pair.second;
for (auto &timer : this->timers_) {
if (timer.is_active && timer.seconds_left > 0) {
timer.seconds_left--;
}
res.push_back(timer);
}
this->timer_tick_trigger_.trigger(res);
this->timer_tick_trigger_.trigger(this->timers_);
}
void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg) {

View File

@@ -24,7 +24,6 @@
#include "esphome/components/socket/socket.h"
#include <span>
#include <unordered_map>
#include <vector>
namespace esphome {
@@ -226,9 +225,9 @@ class VoiceAssistant : public Component {
Trigger<Timer> *get_timer_updated_trigger() { return &this->timer_updated_trigger_; }
Trigger<Timer> *get_timer_cancelled_trigger() { return &this->timer_cancelled_trigger_; }
Trigger<Timer> *get_timer_finished_trigger() { return &this->timer_finished_trigger_; }
Trigger<std::vector<Timer>> *get_timer_tick_trigger() { return &this->timer_tick_trigger_; }
Trigger<const std::vector<Timer> &> *get_timer_tick_trigger() { return &this->timer_tick_trigger_; }
void set_has_timers(bool has_timers) { this->has_timers_ = has_timers; }
const std::unordered_map<std::string, Timer> &get_timers() const { return this->timers_; }
const std::vector<Timer> &get_timers() const { return this->timers_; }
protected:
bool allocate_buffers_();
@@ -267,13 +266,13 @@ class VoiceAssistant : public Component {
api::APIConnection *api_client_{nullptr};
std::unordered_map<std::string, Timer> timers_;
std::vector<Timer> timers_;
void timer_tick_();
Trigger<Timer> timer_started_trigger_;
Trigger<Timer> timer_finished_trigger_;
Trigger<Timer> timer_updated_trigger_;
Trigger<Timer> timer_cancelled_trigger_;
Trigger<std::vector<Timer>> timer_tick_trigger_;
Trigger<const std::vector<Timer> &> timer_tick_trigger_;
bool has_timers_{false};
bool timer_tick_running_{false};

View File

@@ -90,9 +90,22 @@ class WaterHeaterCall {
float get_target_temperature_low() const { return this->target_temperature_low_; }
float get_target_temperature_high() const { return this->target_temperature_high_; }
/// Get state flags value
ESPDEPRECATED("get_state() is deprecated, use get_away() and get_on() instead. (Removed in 2026.8.0)", "2026.2.0")
uint32_t get_state() const { return this->state_; }
/// Get mask of state flags that are being changed
uint32_t get_state_mask() const { return this->state_mask_; }
optional<bool> get_away() const {
if (this->state_mask_ & WATER_HEATER_STATE_AWAY) {
return (this->state_ & WATER_HEATER_STATE_AWAY) != 0;
}
return {};
}
optional<bool> get_on() const {
if (this->state_mask_ & WATER_HEATER_STATE_ON) {
return (this->state_ & WATER_HEATER_STATE_ON) != 0;
}
return {};
}
protected:
void validate_();

View File

@@ -401,7 +401,7 @@ bool WeikaiChannel::peek_byte(uint8_t *buffer) {
return this->receive_buffer_.peek(*buffer);
}
int WeikaiChannel::available() {
size_t WeikaiChannel::available() {
size_t available = this->receive_buffer_.count();
if (!available)
available = xfer_fifo_to_buffer_();

View File

@@ -374,7 +374,7 @@ class WeikaiChannel : public uart::UARTComponent {
/// @brief Returns the number of bytes in the receive buffer
/// @return the number of bytes available in the receiver fifo
int available() override;
size_t available() override;
/// @brief Flush the output fifo.
/// @details If we refer to Serial.flush() in Arduino it says: ** Waits for the transmission of outgoing serial data

View File

@@ -71,11 +71,9 @@ 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)
return str(cv.file_(value))
# Validation result should be the path, not the loaded certificate
return value
def _validate_load_private_key(key, cert_pw):

View File

@@ -3,6 +3,7 @@
#include "esphome/core/log.h"
#include <zephyr/settings/settings.h>
#include <zephyr/storage/flash_map.h>
#include "esphome/core/hal.h"
extern "C" {
#include <zboss_api.h>
@@ -223,6 +224,7 @@ void ZigbeeComponent::dump_config() {
get_wipe_on_boot(), YESNO(zb_zdo_joined()), zb_get_current_channel(), zb_get_current_page(),
zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), extended_pan_id_buf,
zb_get_pan_id());
dump_reporting_();
}
static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) {
@@ -244,6 +246,33 @@ void ZigbeeComponent::factory_reset() {
ZB_SCHEDULE_APP_CALLBACK(zb_bdb_reset_via_local_action, 0);
}
void ZigbeeComponent::dump_reporting_() {
#ifdef ESPHOME_LOG_HAS_VERBOSE
auto now = millis();
bool first = true;
for (zb_uint8_t j = 0; j < ZCL_CTX().device_ctx->ep_count; j++) {
if (ZCL_CTX().device_ctx->ep_desc_list[j]->reporting_info) {
zb_zcl_reporting_info_t *rep_info = ZCL_CTX().device_ctx->ep_desc_list[j]->reporting_info;
for (zb_uint8_t i = 0; i < ZCL_CTX().device_ctx->ep_desc_list[j]->rep_info_count; i++) {
if (!first) {
ESP_LOGV(TAG, "");
}
first = false;
ESP_LOGV(TAG, "Endpoint: %d, cluster_id %d, attr_id %d, flags %d, report in %ums", rep_info->ep,
rep_info->cluster_id, rep_info->attr_id, rep_info->flags,
ZB_ZCL_GET_REPORTING_FLAG(rep_info, ZB_ZCL_REPORT_TIMER_STARTED)
? ZB_TIME_BEACON_INTERVAL_TO_MSEC(rep_info->run_time) - now
: 0);
ESP_LOGV(TAG, "Min_interval %ds, max_interval %ds, def_min_interval %ds, def_max_interval %ds",
rep_info->u.send_info.min_interval, rep_info->u.send_info.max_interval,
rep_info->u.send_info.def_min_interval, rep_info->u.send_info.def_max_interval);
rep_info++;
}
}
}
#endif
}
} // namespace esphome::zigbee
extern "C" void zboss_signal_handler(zb_uint8_t param) {

View File

@@ -87,6 +87,7 @@ class ZigbeeComponent : public Component {
#ifdef USE_ZIGBEE_WIPE_ON_BOOT
void erase_flash_(int area);
#endif
void dump_reporting_();
std::array<std::function<void(zb_bufid_t bufid)>, ZIGBEE_ENDPOINTS_COUNT> callbacks_{};
CallbackManager<void()> join_cb_;
Trigger<> join_trigger_;

View File

@@ -26,6 +26,7 @@ class ComponentIterator {
public:
void begin(bool include_internal = false);
void advance();
bool completed() const { return this->state_ == IteratorState::NONE; }
virtual bool on_begin();
#ifdef USE_BINARY_SENSOR
virtual bool on_binary_sensor(binary_sensor::BinarySensor *binary_sensor) = 0;

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress
from collections.abc import Callable
from contextlib import suppress
import functools
import inspect
from io import BytesIO, TextIOBase, TextIOWrapper
@@ -43,27 +43,6 @@ _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:
@@ -449,8 +428,6 @@ 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)
@@ -458,10 +435,10 @@ def _load_yaml_internal(fname: Path) -> Any:
raise EsphomeError(f"Error reading file {fname}: {err}") from err
def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any:
def parse_yaml(
file_name: Path, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal
) -> 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

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools==80.10.2", "wheel>=0.43,<0.47"]
requires = ["setuptools==82.0.0", "wheel>=0.43,<0.47"]
build-backend = "setuptools.build_meta"
[project]

View File

@@ -0,0 +1,52 @@
display:
- platform: mipi_rgb
spi_id: spi_bus
model: ZX2D10GE01R-V4848
update_interval: 1s
color_order: BGR
draw_rounding: 2
pixel_mode: 18bit
invert_colors: false
use_axis_flips: true
pclk_frequency: 15000000.0
pclk_inverted: true
byte_order: big_endian
hsync_pulse_width: 10
hsync_back_porch: 10
hsync_front_porch: 10
vsync_pulse_width: 2
vsync_back_porch: 12
vsync_front_porch: 14
data_pins:
red:
- number: 10
- number: 16
- number: 9
- number: 15
- number: 46
green:
- number: 8
- number: 13
- number: 18
- number: 12
- number: 11
- number: 17
blue:
- number: 47
- number: 1
- number: 0
- number: 42
- number: 14
de_pin:
number: 39
pclk_pin:
number: 45
hsync_pin:
number: 38
vsync_pin:
number: 48
data_rate: 1000000.0
spi_mode: MODE0
cs_pin:
number: 21
show_test_card: true

View File

@@ -0,0 +1,6 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-p4-idf.yaml
psram:
<<: !include common.yaml

View File

@@ -4,58 +4,4 @@ packages:
psram:
mode: octal
display:
- platform: mipi_rgb
spi_id: spi_bus
model: ZX2D10GE01R-V4848
update_interval: 1s
color_order: BGR
draw_rounding: 2
pixel_mode: 18bit
invert_colors: false
use_axis_flips: true
pclk_frequency: 15000000.0
pclk_inverted: true
byte_order: big_endian
hsync_pulse_width: 10
hsync_back_porch: 10
hsync_front_porch: 10
vsync_pulse_width: 2
vsync_back_porch: 12
vsync_front_porch: 14
data_pins:
red:
- number: 10
- number: 16
- number: 9
- number: 15
- number: 46
ignore_strapping_warning: true
green:
- number: 8
- number: 13
- number: 18
- number: 12
- number: 11
- number: 17
blue:
- number: 47
- number: 1
- number: 0
ignore_strapping_warning: true
- number: 42
- number: 14
de_pin:
number: 39
pclk_pin:
number: 45
ignore_strapping_warning: true
hsync_pin:
number: 38
vsync_pin:
number: 48
data_rate: 1000000.0
spi_mode: MODE0
cs_pin:
number: 21
show_test_card: true
<<: !include common.yaml

View File

@@ -29,7 +29,7 @@ class MockUARTComponent : public UARTComponent {
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(int, available, (), (override));
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(void, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};

View File

@@ -68,3 +68,24 @@ voice_assistant:
- logger.log:
format: "Voice assistant error - code %s, message: %s"
args: [code.c_str(), message.c_str()]
on_timer_started:
- logger.log:
format: "Timer started: %s"
args: [timer.id.c_str()]
on_timer_updated:
- logger.log:
format: "Timer updated: %s"
args: [timer.id.c_str()]
on_timer_cancelled:
- logger.log:
format: "Timer cancelled: %s"
args: [timer.id.c_str()]
on_timer_finished:
- logger.log:
format: "Timer finished: %s"
args: [timer.id.c_str()]
on_timer_tick:
- lambda: |-
for (auto &timer : timers) {
ESP_LOGD("timer", "Timer %s: %" PRIu32 "s left", timer.name.c_str(), timer.seconds_left);
}

View File

@@ -58,3 +58,24 @@ voice_assistant:
- logger.log:
format: "Voice assistant error - code %s, message: %s"
args: [code.c_str(), message.c_str()]
on_timer_started:
- logger.log:
format: "Timer started: %s"
args: [timer.id.c_str()]
on_timer_updated:
- logger.log:
format: "Timer updated: %s"
args: [timer.id.c_str()]
on_timer_cancelled:
- logger.log:
format: "Timer cancelled: %s"
args: [timer.id.c_str()]
on_timer_finished:
- logger.log:
format: "Timer finished: %s"
args: [timer.id.c_str()]
on_timer_tick:
- lambda: |-
for (auto &timer : timers) {
ESP_LOGD("timer", "Timer %s: %" PRIu32 "s left", timer.name.c_str(), timer.seconds_left);
}

View File

@@ -0,0 +1,12 @@
# Common SPI configuration for ESP32-P4 IDF tests
substitutions:
clk_pin: GPIO36
mosi_pin: GPIO32
miso_pin: GPIO33
spi:
- id: spi_bus
clk_pin: ${clk_pin}
mosi_pin: ${mosi_pin}
miso_pin: ${miso_pin}

View File

@@ -1,18 +0,0 @@
-----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-----

View File

@@ -1,18 +0,0 @@
-----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-----

View File

@@ -1,27 +0,0 @@
-----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-----

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 B

View File

@@ -1,2 +0,0 @@
/* Dummy CSS for bundle testing */
body { color: red; }

View File

@@ -1,2 +0,0 @@
// Dummy JS for bundle testing
console.log("test");

View File

@@ -1,60 +0,0 @@
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

View File

@@ -1 +0,0 @@
level: DEBUG

View File

@@ -1,3 +0,0 @@
// Dummy custom sensor header for bundle testing
#pragma once
#include "esphome/core/component.h"

View File

@@ -1 +0,0 @@
# Dummy local external component for bundle testing

View File

@@ -1,2 +0,0 @@
// Dummy component header for bundle testing
#pragma once

View File

@@ -1,4 +0,0 @@
wifi_ssid: "TestNetwork"
wifi_password: "TestPassword123"
api_key: "unused_secret_should_not_appear"
ota_password: "ota_test_password"

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,6 @@ from esphome.__main__ import (
Purpose,
choose_upload_log_host,
command_analyze_memory,
command_bundle,
command_clean_all,
command_rename,
command_update_all,
@@ -42,7 +41,6 @@ from esphome.__main__ import (
upload_program,
upload_using_esptool,
)
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,
@@ -867,8 +865,6 @@ 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(
@@ -3295,195 +3291,3 @@ esp32:
clean_output.split("SUMMARY")[1] if "SUMMARY" in clean_output else ""
)
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

View File

@@ -306,57 +306,3 @@ def test_dump_sort_keys() -> None:
# nested keys should also be sorted
assert "a_key:" in sorted_dump
assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:")
# ---------------------------------------------------------------------------
# track_yaml_loads
# ---------------------------------------------------------------------------
def test_track_yaml_loads_records_files(tmp_path: Path) -> None:
"""track_yaml_loads records every file loaded inside the context."""
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text("key: value\n")
with yaml_util.track_yaml_loads() as loaded:
yaml_util.load_yaml(yaml_file)
assert len(loaded) == 1
assert loaded[0] == yaml_file.resolve()
def test_track_yaml_loads_records_includes(tmp_path: Path) -> None:
"""track_yaml_loads records nested !include files."""
inc = tmp_path / "included.yaml"
inc.write_text("included_key: 42\n")
main = tmp_path / "main.yaml"
main.write_text("child: !include included.yaml\n")
with yaml_util.track_yaml_loads() as loaded:
yaml_util.load_yaml(main)
resolved = [p.name for p in loaded]
assert "main.yaml" in resolved
assert "included.yaml" in resolved
def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None:
"""Files loaded outside the context are not recorded."""
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text("key: value\n")
with yaml_util.track_yaml_loads() as loaded:
pass # load nothing inside
yaml_util.load_yaml(yaml_file)
assert loaded == []
def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None:
"""Listener is removed even if the body raises."""
before = len(yaml_util._load_listeners)
with pytest.raises(RuntimeError), yaml_util.track_yaml_loads():
raise RuntimeError("boom")
assert len(yaml_util._load_listeners) == before