Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-04-07 14:31:13 -10:00
120 changed files with 4181 additions and 520 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
+1
View File
@@ -148,6 +148,7 @@ esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
esphome/components/emmeti/* @E440QF
esphome/components/emontx/* @FredM67 @glynhudson @TrystanLea
esphome/components/ens160/* @latonita
esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
+61
View File
@@ -1242,6 +1242,38 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
creator = ConfigBundleCreator(config)
if args.list_only:
files = creator.discover_files()
for bf in sorted(files, key=lambda f: f.path):
safe_print(f" {bf.path}")
_LOGGER.info("Found %d files", len(files))
return 0
result = creator.create_bundle()
if args.output:
output_path = Path(args.output)
else:
stem = CORE.config_path.stem
output_path = CORE.config_dir / f"{stem}{BUNDLE_EXTENSION}"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(result.data)
_LOGGER.info(
"Bundle created: %s (%d files, %.1f KB)",
output_path,
len(result.files),
len(result.data) / 1024,
)
return 0
def command_dashboard(args: ArgsProtocol) -> int | None:
from esphome.dashboard import dashboard
@@ -1517,6 +1549,7 @@ POST_CONFIG_ACTIONS = {
"rename": command_rename,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
"bundle": command_bundle,
}
SIMPLE_CONFIG_ACTIONS = [
@@ -1818,6 +1851,24 @@ def parse_args(argv):
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_bundle = subparsers.add_parser(
"bundle",
help="Create a self-contained config bundle for remote compilation.",
)
parser_bundle.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_bundle.add_argument(
"-o",
"--output",
help="Output path for the bundle archive.",
)
parser_bundle.add_argument(
"--list-only",
help="List discovered files without creating the archive.",
action="store_true",
)
# Keep backward compatibility with the old command line format of
# esphome <config> <command>.
#
@@ -1896,6 +1947,16 @@ def run_esphome(argv):
_LOGGER.warning("Skipping secrets file %s", conf_path)
return 0
# Bundle support: if the configuration is a .esphomebundle, extract it
# and rewrite conf_path to the extracted YAML config.
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
if is_bundle_path(conf_path):
_LOGGER.info("Extracting config bundle %s...", conf_path)
conf_path = prepare_bundle_for_compile(conf_path)
# Update the argument so downstream code sees the extracted path
args.configuration[0] = str(conf_path)
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
+33
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass, field
import logging
import esphome.codegen as cg
@@ -715,3 +716,35 @@ async def build_callback_automation(
# MockObjs (not user input), and there's no Expression type for positional
# aggregate initialization (StructInitializer uses named fields).
cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}")))
@dataclass(frozen=True, slots=True)
class CallbackAutomation:
"""A single callback automation entry for build_callback_automations."""
conf_key: str
callback_method: str
args: TemplateArgsType = field(default_factory=list)
forwarder: MockObj | MockObjClass | None = None
async def build_callback_automations(
parent: MockObj,
config: ConfigType,
entries: tuple[CallbackAutomation, ...],
) -> None:
"""Build multiple callback automations from a tuple of entries.
:param parent: The component object (e.g., button, sensor).
:param config: The full component config dict.
:param entries: Tuple of CallbackAutomation entries to process.
"""
for entry in entries:
for conf in config.get(entry.conf_key, []):
await build_callback_automation(
parent,
entry.callback_method,
entry.args,
conf,
forwarder=entry.forwarder,
)
+699
View File
@@ -0,0 +1,699 @@
"""Config bundle creator and extractor for ESPHome.
A bundle is a self-contained .tar.gz archive containing a YAML config
and every local file it depends on. Bundles can be created from a config
and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import io
import json
import logging
from pathlib import Path
import re
import shutil
import tarfile
from typing import Any
from esphome import const, yaml_util
from esphome.const import (
CONF_ESPHOME,
CONF_EXTERNAL_COMPONENTS,
CONF_INCLUDES,
CONF_INCLUDES_C,
CONF_PATH,
CONF_SOURCE,
CONF_TYPE,
)
from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB
# Directories preserved across bundle extractions (build caches)
_PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio")
_BUNDLE_STAGING_DIR = ".bundle_staging"
class ManifestKey(StrEnum):
"""Keys used in bundle manifest.json."""
MANIFEST_VERSION = "manifest_version"
ESPHOME_VERSION = "esphome_version"
CONFIG_FILENAME = "config_filename"
FILES = "files"
HAS_SECRETS = "has_secrets"
# String prefixes that are never local file paths
_NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<")
# File extensions recognized when resolving relative path strings.
# A relative string with one of these extensions is resolved against the
# config directory and included if the file exists.
_KNOWN_FILE_EXTENSIONS = frozenset(
{
# Fonts
".ttf",
".otf",
".woff",
".woff2",
".pcf",
".bdf",
# Images
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".svg",
".ico",
".webp",
# Certificates
".pem",
".crt",
".key",
".der",
".p12",
".pfx",
# C/C++ includes
".h",
".hpp",
".c",
".cpp",
".ino",
# Web assets
".css",
".js",
".html",
}
)
# Matches !secret references in YAML text. This is intentionally a simple
# regex scan rather than a YAML parse — it may match inside comments or
# multi-line strings, which is the conservative direction (include more
# secrets rather than fewer).
_SECRET_RE = re.compile(r"!secret\s+(\S+)")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
"""Scan YAML files for ``!secret <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
@@ -111,42 +111,66 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder
),
automation.CallbackAutomation(
CONF_ON_TRIGGERED,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_TRIGGERED
),
),
automation.CallbackAutomation(
CONF_ON_ARMING,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING),
),
automation.CallbackAutomation(
CONF_ON_PENDING,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_PENDING
),
),
automation.CallbackAutomation(
CONF_ON_ARMED_HOME,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_ARMED_HOME
),
),
automation.CallbackAutomation(
CONF_ON_ARMED_NIGHT,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_ARMED_NIGHT
),
),
automation.CallbackAutomation(
CONF_ON_ARMED_AWAY,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_ARMED_AWAY
),
),
automation.CallbackAutomation(
CONF_ON_DISARMED,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
AlarmControlPanelState.ACP_STATE_DISARMED
),
),
automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"),
automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"),
automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"),
)
@setup_entity("alarm_control_panel")
async def setup_alarm_control_panel_core_(var, config):
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=StateAnyForwarder
)
_STATE_ENTER_MAP = {
CONF_ON_TRIGGERED: AlarmControlPanelState.ACP_STATE_TRIGGERED,
CONF_ON_ARMING: AlarmControlPanelState.ACP_STATE_ARMING,
CONF_ON_PENDING: AlarmControlPanelState.ACP_STATE_PENDING,
CONF_ON_ARMED_HOME: AlarmControlPanelState.ACP_STATE_ARMED_HOME,
CONF_ON_ARMED_NIGHT: AlarmControlPanelState.ACP_STATE_ARMED_NIGHT,
CONF_ON_ARMED_AWAY: AlarmControlPanelState.ACP_STATE_ARMED_AWAY,
CONF_ON_DISARMED: AlarmControlPanelState.ACP_STATE_DISARMED,
}
for conf_key, state_enum in _STATE_ENTER_MAP.items():
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var,
"add_on_state_callback",
[],
conf,
forwarder=StateEnterForwarder.template(state_enum),
)
for conf in config.get(CONF_ON_CLEARED, []):
await automation.build_callback_automation(
var, "add_on_cleared_callback", [], conf
)
for conf in config.get(CONF_ON_CHIME, []):
await automation.build_callback_automation(
var, "add_on_chime_callback", [], conf
)
for conf in config.get(CONF_ON_READY, []):
await automation.build_callback_automation(
var, "add_on_ready_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
if mqtt_id := config.get(CONF_MQTT_ID):
+1 -1
View File
@@ -12,7 +12,7 @@ CODEOWNERS = ["@B48D81EFCC"]
sensor_ns = cg.esphome_ns.namespace("bh1900nux")
BH1900NUXSensor = sensor_ns.class_(
"BH1900NUXSensor", cg.PollingComponent, i2c.I2CDevice
"BH1900NUXSensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
+23 -24
View File
@@ -531,16 +531,31 @@ def binary_sensor_schema(
return _BINARY_SENSOR_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_PRESS,
"add_on_state_callback",
forwarder=automation.TriggerOnTrueForwarder,
),
automation.CallbackAutomation(
CONF_ON_RELEASE,
"add_on_state_callback",
forwarder=automation.TriggerOnFalseForwarder,
),
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", [(bool, "x")]
),
automation.CallbackAutomation(
CONF_ON_STATE_CHANGE,
"add_full_state_callback",
[(cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x")],
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_binary_sensor_automations(var, config):
for conf_key, forwarder in (
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=forwarder
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
for conf in config.get(CONF_ON_CLICK, []):
trigger = cg.new_Pvariable(
@@ -572,22 +587,6 @@ async def _build_binary_sensor_automations(var, config):
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(bool, "x")], conf
)
for conf in config.get(CONF_ON_STATE_CHANGE, []):
await automation.build_callback_automation(
var,
"add_full_state_callback",
[
(cg.optional.template(bool), "x_previous"),
(cg.optional.template(bool), "x"),
],
conf,
)
@setup_entity("binary_sensor")
async def setup_binary_sensor_core_(var, config):
+1 -1
View File
@@ -14,7 +14,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_BP1658CJ_ID): cv.use_id(BP1658CJ),
cv.Required(CONF_ID): cv.declare_id(Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4),
}
).extend(cv.COMPONENT_SCHEMA)
+6 -4
View File
@@ -79,12 +79,14 @@ def button_schema(
return _BUTTON_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_PRESS, "add_on_press_callback"),
)
@setup_entity("button")
async def setup_button_core_(var, config):
for conf in config.get(CONF_ON_PRESS, []):
await automation.build_callback_automation(
var, "add_on_press_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
setup_device_class(config)
+8 -4
View File
@@ -64,15 +64,19 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_FINISHED_PLAYBACK, "add_on_finished_playback_callback"
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []):
await automation.build_callback_automation(
var, "add_on_finished_playback_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_action(
+1 -1
View File
@@ -37,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_,
cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_,
cv.Optional(CONF_THERMAL_MBUS_ID, default=3): cv.int_,
cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_,
cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_range(min=1),
cv.Optional(CONF_REQUEST_PIN): pins.gpio_output_pin_schema,
cv.Optional(
CONF_REQUEST_INTERVAL, default="0ms"
+152
View File
@@ -0,0 +1,152 @@
from dataclasses import dataclass, field
from esphome import automation
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import (
CONF_COMMAND,
CONF_ID,
CONF_ON_DATA,
CONF_RX_BUFFER_SIZE,
CONF_UART_ID,
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
AUTO_LOAD = ["json"]
CODEOWNERS = ["@FredM67", "@TrystanLea", "@glynhudson"]
DEPENDENCIES = ["uart"]
emontx_ns = cg.esphome_ns.namespace("emontx")
EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice)
# Action to send command to emonTx
EmonTxSendCommandAction = emontx_ns.class_("EmonTxSendCommandAction", automation.Action)
CONF_EMONTX_ID = "emontx_id"
CONF_TAG_NAME = "tag_name"
CONF_ON_JSON = "on_json"
DOMAIN = "emontx"
MINIMUM_RX_BUFFER_SIZE = 2048
@dataclass
class EmonTxData:
sensor_counts: dict[str, int] = field(default_factory=dict)
def _get_data() -> EmonTxData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = EmonTxData()
return CORE.data[DOMAIN]
# Main configuration schema
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(EmonTx),
cv.Optional(CONF_ON_JSON): automation.validate_automation({}),
cv.Optional(CONF_ON_DATA): automation.validate_automation({}),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
def final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
# Count sensors registered to this hub (IDs are resolved at final_validate stage)
hub_id = str(config[CONF_ID])
sensor_count = sum(
1
for s in full_config.get("sensor", [])
if s.get("platform") == "emontx" and str(s.get(CONF_EMONTX_ID)) == hub_id
)
_get_data().sensor_counts[hub_id] = sensor_count
# Ensure UART RX buffer size is large enough to handle data bursts from firmware
for uart_conf in full_config["uart"]:
if uart_conf[CONF_ID] == config[CONF_UART_ID]:
current_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE]
if current_buffer_size < MINIMUM_RX_BUFFER_SIZE:
raise cv.Invalid(
f"Component emontx requires UART '{config[CONF_UART_ID]}' to have "
f"rx_buffer_size of at least {MINIMUM_RX_BUFFER_SIZE} bytes "
f"(currently set to {current_buffer_size} bytes). "
f"Please add 'rx_buffer_size: {MINIMUM_RX_BUFFER_SIZE}' to your uart configuration.",
path=[CONF_UART_ID],
)
break
# Validate UART settings
schema = uart.final_validate_device_schema(
"emontx",
baud_rate=115200,
require_tx=False,
require_rx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
return schema(config)
FINAL_VALIDATE_SCHEMA = final_validate
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_JSON,
"add_on_json_callback",
[(cg.JsonObject, "json"), (cg.std_string, "raw_json")],
),
automation.CallbackAutomation(
CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")]
),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
# Initialize sensor storage with count from final_validate
sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0)
if sensor_count > 0:
cg.add(var.init_sensors(sensor_count))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
# Action: emontx.send_command
EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(EmonTx),
cv.Required(CONF_COMMAND): cv.templatable(cv.string),
}
)
@automation.register_action(
"emontx.send_command",
EmonTxSendCommandAction,
EMONTX_SEND_COMMAND_ACTION_SCHEMA,
synchronous=True,
)
async def emontx_send_command_action_to_code(
config: ConfigType, action_id, template_arg, args
) -> None:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string)
cg.add(var.set_command(template_))
return var
+116
View File
@@ -0,0 +1,116 @@
#include "emontx.h"
#include "esphome/core/log.h"
#include "esphome/components/json/json_util.h"
namespace esphome::emontx {
static const char *const TAG = "emontx";
void EmonTx::setup() { this->buffer_pos_ = 0; }
/**
* @brief Implements the main loop for parsing data from the serial port.
*
* @details Continuously processes incoming UART data line-by-line:
* 1. Fire on_data callbacks for all received lines
* 2. If line starts with '{', parse as JSON and update sensors/callbacks
*/
void EmonTx::loop() {
// Read all available data to prevent UART buffer overflow
while (this->available() > 0) {
uint8_t received = this->read();
if (received == '\r') {
continue; // Ignore CR
} else if (received == '\n') {
// End of line - process the buffer
if (this->buffer_pos_ > 0) {
// Null-terminate for safe logging and c_str() use
size_t len = this->buffer_pos_;
this->buffer_[len] = '\0';
this->buffer_pos_ = 0;
StringRef line(this->buffer_.data(), len);
ESP_LOGD(TAG, "Received line: %s", line.c_str());
// Fire data callbacks for all received lines
this->data_callbacks_.call(line);
// Check if this line is JSON (starts with '{')
if (this->buffer_[0] == '{') {
ESP_LOGV(TAG, "Line is JSON, parsing...");
this->parse_json_(this->buffer_.data(), len);
}
}
} else if (this->buffer_pos_ >= MAX_LINE_LENGTH) {
ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH);
this->buffer_pos_ = 0;
} else {
this->buffer_[this->buffer_pos_++] = static_cast<char>(received);
}
}
}
void EmonTx::parse_json_(const char *data, size_t len) {
bool success = json::parse_json(reinterpret_cast<const uint8_t *>(data), len, [this, data, len](JsonObject root) {
#ifdef USE_SENSOR
for (auto &sensor_pair : this->sensors_) {
auto val = root[sensor_pair.first];
if (val.is<JsonVariant>()) {
float value = val;
ESP_LOGV(TAG, "Updating sensor '%s' with value: %.2f", sensor_pair.first, value);
sensor_pair.second->publish_state(value);
}
}
#endif
this->json_callbacks_.call(root, StringRef(data, len));
return true;
});
if (!success) {
ESP_LOGW(TAG, "Failed to parse JSON");
}
}
/**
* @brief Logs the EmonTx component configuration details.
*/
void EmonTx::dump_config() {
ESP_LOGCONFIG(TAG, "EmonTx:");
#ifdef USE_SENSOR
ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size());
for (const auto &sensor_pair : this->sensors_) {
ESP_LOGCONFIG(TAG, " Sensor: %s", sensor_pair.first);
}
#else
ESP_LOGCONFIG(TAG, " Sensor support: DISABLED");
#endif
}
/**
* @brief Sends a command string to the emonTx device via UART.
*
* @param command The command string to send (LF will be appended automatically).
*/
void EmonTx::send_command(const std::string &command) {
ESP_LOGD(TAG, "Sending command to emonTx: %s", command.c_str());
this->write_str(command.c_str());
this->write_byte('\n');
}
#ifdef USE_SENSOR
/**
* @brief Registers a sensor to receive updates for a specific JSON tag.
*
* @param tag_name The JSON key to monitor for this sensor (must be a string literal).
* @param sensor Pointer to the sensor that will receive value updates.
*/
void EmonTx::register_sensor(const char *tag_name, sensor::Sensor *sensor) {
ESP_LOGCONFIG(TAG, "Registering sensor for tag: %s", tag_name);
this->sensors_.emplace_back(tag_name, sensor);
}
#endif
} // namespace esphome::emontx
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/uart/uart.h"
#include "esphome/components/json/json_util.h"
#include <array>
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
namespace esphome::emontx {
/// Maximum line length in bytes (plus one byte reserved for null terminator)
static constexpr size_t MAX_LINE_LENGTH = 1024;
/**
* @class EmonTx
* @brief Main class for the EmonTx component.
*
* The EmonTx processes incoming data frames via UART,
* extracts tags and values, and publishes them to registered sensors.
*/
class EmonTx : public Component, public uart::UARTDevice {
public:
EmonTx() = default;
void loop() override;
void setup() override;
void dump_config() override;
template<typename F> void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward<F>(callback)); }
template<typename F> void add_on_data_callback(F &&callback) { this->data_callbacks_.add(std::forward<F>(callback)); }
// Send command to emonTx via UART
void send_command(const std::string &command);
#ifdef USE_SENSOR
void init_sensors(size_t count) { this->sensors_.init(count); }
void register_sensor(const char *tag_name, sensor::Sensor *sensor);
#endif
protected:
void parse_json_(const char *data, size_t len);
#ifdef USE_SENSOR
FixedVector<std::pair<const char *, sensor::Sensor *>> sensors_{};
#endif
LazyCallbackManager<void(JsonObject, StringRef)> json_callbacks_;
LazyCallbackManager<void(StringRef)> data_callbacks_;
uint16_t buffer_pos_{0};
std::array<char, MAX_LINE_LENGTH + 1> buffer_{};
};
// Action to send command to emonTx
template<typename... Ts> class EmonTxSendCommandAction : public Action<Ts...>, public Parented<EmonTx> {
public:
TEMPLATABLE_VALUE(std::string, command)
void play(const Ts &...x) override { this->parent_->send_command(this->command_.value(x...)); }
};
} // namespace esphome::emontx
@@ -0,0 +1,133 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_PULSES,
UNIT_VOLT,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
from .. import CONF_EMONTX_ID, CONF_TAG_NAME, EmonTx, emontx_ns
EmonTxSensor = emontx_ns.class_("EmonTxSensor", sensor.Sensor, cg.Component)
# Define sensor type configurations by prefix
SENSOR_CONFIGS = {
"P": {
CONF_UNIT_OF_MEASUREMENT: UNIT_WATT,
CONF_DEVICE_CLASS: DEVICE_CLASS_POWER,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 0,
},
"E": {
CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS,
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
CONF_ACCURACY_DECIMALS: 0,
},
"V": {
CONF_UNIT_OF_MEASUREMENT: UNIT_VOLT,
CONF_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
"I": {
CONF_UNIT_OF_MEASUREMENT: UNIT_AMPERE,
CONF_DEVICE_CLASS: DEVICE_CLASS_CURRENT,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
"T": {
CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS,
CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
}
# Pattern-based configurations
PATTERN_CONFIGS = {
"PULSE": {
CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES,
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
CONF_ACCURACY_DECIMALS: 0,
},
"PF": {
CONF_UNIT_OF_MEASUREMENT: UNIT_EMPTY,
CONF_DEVICE_CLASS: DEVICE_CLASS_POWER_FACTOR,
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 2,
},
}
# Create a base schema that's flexible for any tag
BASE_SCHEMA = sensor.sensor_schema(
EmonTxSensor,
state_class=STATE_CLASS_MEASUREMENT,
accuracy_decimals=0,
).extend(
{
cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx),
cv.Required(CONF_TAG_NAME): cv.string,
}
)
def apply_tag_defaults(config: ConfigType) -> ConfigType:
"""Apply defaults based on tag prefix if applicable, but don't restrict any tags."""
tag = config[CONF_TAG_NAME]
# Skip if tag is too short
if len(tag) < 2:
return config
# Check if this tag starts with a known prefix
tag_upper = tag.upper()
for pattern, pattern_config in PATTERN_CONFIGS.items():
if tag_upper.startswith(pattern):
# Apply pattern defaults if not overridden by user
for key, value in pattern_config.items():
if key not in config:
config[key] = value
return config
# Only apply defaults for known prefixes with numeric indices
prefix = tag_upper[0]
if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit():
# Apply defaults for known tag types, but only if not overridden by user
defaults = SENSOR_CONFIGS[prefix]
for key, value in defaults.items():
if key not in config:
config[key] = value
return config
CONFIG_SCHEMA = cv.All(BASE_SCHEMA, apply_tag_defaults)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
hub = await cg.get_variable(config[CONF_EMONTX_ID])
cg.add(hub.register_sensor(config[CONF_TAG_NAME], var))
@@ -0,0 +1,10 @@
#include "emontx_sensor.h"
#include "esphome/core/log.h"
namespace esphome::emontx {
static const char *const TAG = "emontx_sensor";
void EmonTxSensor::dump_config() { LOG_SENSOR(" ", "EmonTx Sensor", this); }
} // namespace esphome::emontx
@@ -0,0 +1,13 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::emontx {
class EmonTxSensor : public sensor::Sensor, public Component {
public:
void dump_config() override;
};
} // namespace esphome::emontx
+8 -4
View File
@@ -82,12 +82,16 @@ def event_schema(
return _EVENT_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_EVENT, "add_on_event_callback", [(cg.StringRef, "event_type")]
),
)
@setup_entity("event")
async def setup_event_core_(var, config, *, event_types: list[str]):
for conf in config.get(CONF_ON_EVENT, []):
await automation.build_callback_automation(
var, "add_on_event_callback", [(cg.StringRef, "event_type")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
cg.add(var.set_event_types(event_types))
+21 -24
View File
@@ -38,33 +38,30 @@ CONFIG_SCHEMA = (
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_CUSTOM, "add_custom_callback", [(cg.std_string, "x")]
),
automation.CallbackAutomation(CONF_ON_LED, "add_led_state_callback", [(bool, "x")]),
automation.CallbackAutomation(
CONF_ON_DEVICE_INFORMATION,
"add_device_infomation_callback",
[(cg.std_string, "x")],
),
automation.CallbackAutomation(
CONF_ON_SLOPE, "add_slope_callback", [(cg.std_string, "x")]
),
automation.CallbackAutomation(
CONF_ON_CALIBRATION, "add_calibration_callback", [(cg.std_string, "x")]
),
automation.CallbackAutomation(CONF_ON_T, "add_t_callback", [(cg.std_string, "x")]),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
await i2c.register_i2c_device(var, config)
for conf in config.get(CONF_ON_CUSTOM, []):
await automation.build_callback_automation(
var, "add_custom_callback", [(cg.std_string, "x")], conf
)
for conf in config.get(CONF_ON_LED, []):
await automation.build_callback_automation(
var, "add_led_state_callback", [(bool, "x")], conf
)
for conf in config.get(CONF_ON_DEVICE_INFORMATION, []):
await automation.build_callback_automation(
var, "add_device_infomation_callback", [(cg.std_string, "x")], conf
)
for conf in config.get(CONF_ON_SLOPE, []):
await automation.build_callback_automation(
var, "add_slope_callback", [(cg.std_string, "x")], conf
)
for conf in config.get(CONF_ON_CALIBRATION, []):
await automation.build_callback_automation(
var, "add_calibration_callback", [(cg.std_string, "x")], conf
)
for conf in config.get(CONF_ON_T, []):
await automation.build_callback_automation(
var, "add_t_callback", [(cg.std_string, "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+10 -7
View File
@@ -73,6 +73,15 @@ def _final_validate(config):
FINAL_VALIDATE_SCHEMA = _final_validate
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_INCREMENT,
"add_increment_callback",
[(cg.uint8, "x"), (cg.uint8, "target")],
),
)
async def to_code(config):
if reset_count := config.get(CONF_RESETS_REQUIRED):
var = cg.new_Pvariable(
@@ -81,10 +90,4 @@ async def to_code(config):
config[CONF_MAX_DELAY].total_seconds,
)
await cg.register_component(var, config)
for conf in config.get(CONF_ON_INCREMENT, []):
await automation.build_callback_automation(
var,
"add_increment_callback",
[(cg.uint8, "x"), (cg.uint8, "target")],
conf,
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+39 -38
View File
@@ -116,6 +116,44 @@ CONFIG_SCHEMA = cv.All(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_FINGER_SCAN_START, "add_on_finger_scan_start_callback"
),
automation.CallbackAutomation(
CONF_ON_FINGER_SCAN_MATCHED,
"add_on_finger_scan_matched_callback",
[(cg.uint16, "finger_id"), (cg.uint16, "confidence")],
),
automation.CallbackAutomation(
CONF_ON_FINGER_SCAN_UNMATCHED,
"add_on_finger_scan_unmatched_callback",
),
automation.CallbackAutomation(
CONF_ON_FINGER_SCAN_MISPLACED,
"add_on_finger_scan_misplaced_callback",
),
automation.CallbackAutomation(
CONF_ON_FINGER_SCAN_INVALID, "add_on_finger_scan_invalid_callback"
),
automation.CallbackAutomation(
CONF_ON_ENROLLMENT_SCAN,
"add_on_enrollment_scan_callback",
[(cg.uint8, "scan_num"), (cg.uint16, "finger_id")],
),
automation.CallbackAutomation(
CONF_ON_ENROLLMENT_DONE,
"add_on_enrollment_done_callback",
[(cg.uint16, "finger_id")],
),
automation.CallbackAutomation(
CONF_ON_ENROLLMENT_FAILED,
"add_on_enrollment_failed_callback",
[(cg.uint16, "finger_id")],
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -140,44 +178,7 @@ async def to_code(config):
idle_period_to_sleep_ms = config[CONF_IDLE_PERIOD_TO_SLEEP]
cg.add(var.set_idle_period_to_sleep_ms(idle_period_to_sleep_ms))
for conf in config.get(CONF_ON_FINGER_SCAN_START, []):
await automation.build_callback_automation(
var, "add_on_finger_scan_start_callback", [], conf
)
for conf in config.get(CONF_ON_FINGER_SCAN_MATCHED, []):
await automation.build_callback_automation(
var,
"add_on_finger_scan_matched_callback",
[(cg.uint16, "finger_id"), (cg.uint16, "confidence")],
conf,
)
for conf in config.get(CONF_ON_FINGER_SCAN_UNMATCHED, []):
await automation.build_callback_automation(
var, "add_on_finger_scan_unmatched_callback", [], conf
)
for conf in config.get(CONF_ON_FINGER_SCAN_MISPLACED, []):
await automation.build_callback_automation(
var, "add_on_finger_scan_misplaced_callback", [], conf
)
for conf in config.get(CONF_ON_FINGER_SCAN_INVALID, []):
await automation.build_callback_automation(
var, "add_on_finger_scan_invalid_callback", [], conf
)
for conf in config.get(CONF_ON_ENROLLMENT_SCAN, []):
await automation.build_callback_automation(
var,
"add_on_enrollment_scan_callback",
[(cg.uint8, "scan_num"), (cg.uint16, "finger_id")],
conf,
)
for conf in config.get(CONF_ON_ENROLLMENT_DONE, []):
await automation.build_callback_automation(
var, "add_on_enrollment_done_callback", [(cg.uint16, "finger_id")], conf
)
for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []):
await automation.build_callback_automation(
var, "add_on_enrollment_failed_callback", [(cg.uint16, "finger_id")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_action(
+1 -1
View File
@@ -13,7 +13,7 @@ DEPENDENCIES = ["i2c"]
gl_r01_i2c_ns = cg.esphome_ns.namespace("gl_r01_i2c")
GLR01I2CComponent = gl_r01_i2c_ns.class_(
"GLR01I2CComponent", i2c.I2CDevice, cg.PollingComponent
"GLR01I2CComponent", sensor.Sensor, i2c.I2CDevice, cg.PollingComponent
)
CONFIG_SCHEMA = (
+2 -2
View File
@@ -110,7 +110,7 @@ GRAPH_SCHEMA = cv.Schema(
cv.Optional(CONF_MIN_RANGE): cv.float_range(min=0, min_included=False),
cv.Optional(CONF_MAX_RANGE): cv.float_range(min=0, min_included=False),
cv.Optional(CONF_TRACES): cv.ensure_list(GRAPH_TRACE_SCHEMA),
cv.Optional(CONF_LEGEND): cv.ensure_list(GRAPH_LEGEND_SCHEMA),
cv.Optional(CONF_LEGEND): GRAPH_LEGEND_SCHEMA,
}
)
@@ -192,7 +192,7 @@ async def to_code(config):
cg.add(var.add_trace(tr))
# Add legend
if CONF_LEGEND in config:
lgd = config[CONF_LEGEND][0]
lgd = config[CONF_LEGEND]
legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend())
if CONF_NAME_FONT in lgd:
font = await cg.get_variable(lgd[CONF_NAME_FONT])
@@ -80,11 +80,9 @@ async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args):
template_channel = await cg.templatable(config[CONF_CHANNEL], args, int)
template_speed = await cg.templatable(config[CONF_SPEED], args, cg.uint16)
template_speed = (
template_speed if config[CONF_DIRECTION] == "FORWARD" else -template_speed
)
cg.add(var.set_channel(template_channel))
cg.add(var.set_speed(template_speed))
cg.add(var.set_direction(config[CONF_DIRECTION] == "FORWARD"))
return var
@@ -168,11 +168,19 @@ class GROVETB6612FNGMotorRunAction : public Action<Ts...>, public Parented<Grove
TEMPLATABLE_VALUE(uint8_t, channel)
TEMPLATABLE_VALUE(uint16_t, speed)
void set_direction(bool forward) { this->forward_ = forward; }
void play(const Ts &...x) override {
auto channel = this->channel_.value(x...);
auto speed = this->speed_.value(x...);
int16_t speed = this->speed_.value(x...);
if (!this->forward_) {
speed = -speed;
}
this->parent_->dc_motor_run(channel, speed);
}
protected:
bool forward_{true};
};
template<typename... Ts>
+21 -24
View File
@@ -215,9 +215,7 @@ CONFIG_SCHEMA = cv.All(
{
cv.Optional(
CONF_CONTROL_METHOD, default="SET_GROUP_PARAMETERS"
): cv.ensure_list(
cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True)
),
): cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True),
cv.Optional(CONF_BEEPER): cv.invalid(
f"The {CONF_BEEPER} option is deprecated, use beeper_on/beeper_off actions or beeper switch for a haier platform instead"
),
@@ -456,6 +454,25 @@ def _final_validate(config):
FINAL_VALIDATE_SCHEMA = _final_validate
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_ALARM_START,
"add_alarm_start_callback",
[(cg.uint8, "code"), (cg.const_char_ptr, "message")],
),
automation.CallbackAutomation(
CONF_ON_ALARM_END,
"add_alarm_end_callback",
[(cg.uint8, "code"), (cg.const_char_ptr, "message")],
),
automation.CallbackAutomation(
CONF_ON_STATUS_MESSAGE,
"add_status_message_callback",
[(cg.const_char_ptr, "data"), (cg.size_t, "data_size")],
),
)
async def to_code(config):
cg.add(haier_ns.init_haier_protocol_logging())
var = await climate.new_climate(config)
@@ -497,26 +514,6 @@ async def to_code(config):
cg.add(
var.set_status_message_header_size(config[CONF_STATUS_MESSAGE_HEADER_SIZE])
)
for conf in config.get(CONF_ON_ALARM_START, []):
await automation.build_callback_automation(
var,
"add_alarm_start_callback",
[(cg.uint8, "code"), (cg.const_char_ptr, "message")],
conf,
)
for conf in config.get(CONF_ON_ALARM_END, []):
await automation.build_callback_automation(
var,
"add_alarm_end_callback",
[(cg.uint8, "code"), (cg.const_char_ptr, "message")],
conf,
)
for conf in config.get(CONF_ON_STATUS_MESSAGE, []):
await automation.build_callback_automation(
var,
"add_status_message_callback",
[(cg.const_char_ptr, "data"), (cg.size_t, "data_size")],
conf,
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
# https://github.com/paveldn/HaierProtocol
cg.add_library("pavlodn/HaierProtocol", "0.9.31")
+43 -48
View File
@@ -52,58 +52,53 @@ CONFIG_SCHEMA = cv.All(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_FACE_SCAN_MATCHED,
"add_on_face_scan_matched_callback",
[(cg.int16, "face_id"), (cg.std_string, "name")],
),
automation.CallbackAutomation(
CONF_ON_FACE_SCAN_UNMATCHED, "add_on_face_scan_unmatched_callback"
),
automation.CallbackAutomation(
CONF_ON_FACE_SCAN_INVALID,
"add_on_face_scan_invalid_callback",
[(cg.uint8, "error")],
),
automation.CallbackAutomation(
CONF_ON_FACE_INFO,
"add_on_face_info_callback",
[
(cg.int16, "status"),
(cg.int16, "left"),
(cg.int16, "top"),
(cg.int16, "right"),
(cg.int16, "bottom"),
(cg.int16, "yaw"),
(cg.int16, "pitch"),
(cg.int16, "roll"),
],
),
automation.CallbackAutomation(
CONF_ON_ENROLLMENT_DONE,
"add_on_enrollment_done_callback",
[(cg.int16, "face_id"), (cg.uint8, "direction")],
),
automation.CallbackAutomation(
CONF_ON_ENROLLMENT_FAILED,
"add_on_enrollment_failed_callback",
[(cg.uint8, "error")],
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []):
await automation.build_callback_automation(
var,
"add_on_face_scan_matched_callback",
[(cg.int16, "face_id"), (cg.std_string, "name")],
conf,
)
for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []):
await automation.build_callback_automation(
var, "add_on_face_scan_unmatched_callback", [], conf
)
for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []):
await automation.build_callback_automation(
var, "add_on_face_scan_invalid_callback", [(cg.uint8, "error")], conf
)
for conf in config.get(CONF_ON_FACE_INFO, []):
await automation.build_callback_automation(
var,
"add_on_face_info_callback",
[
(cg.int16, "status"),
(cg.int16, "left"),
(cg.int16, "top"),
(cg.int16, "right"),
(cg.int16, "bottom"),
(cg.int16, "yaw"),
(cg.int16, "pitch"),
(cg.int16, "roll"),
],
conf,
)
for conf in config.get(CONF_ON_ENROLLMENT_DONE, []):
await automation.build_callback_automation(
var,
"add_on_enrollment_done_callback",
[(cg.int16, "face_id"), (cg.uint8, "direction")],
conf,
)
for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []):
await automation.build_callback_automation(
var, "add_on_enrollment_failed_callback", [(cg.uint8, "error")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_action(
@@ -136,7 +131,7 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args):
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(HlkFm22xComponent),
cv.Required(CONF_FACE_ID): cv.templatable(cv.uint16_t),
cv.Required(CONF_FACE_ID): cv.templatable(cv.int_range(min=0, max=32767)),
},
key=CONF_FACE_ID,
),
+2 -2
View File
@@ -12,7 +12,7 @@ CONF_SELECTS = [
"Simple",
]
LD2420Select = ld2420_ns.class_("LD2420Select", cg.Component)
LD2420Select = ld2420_ns.class_("LD2420Select", select.Select, cg.Component)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
@@ -28,7 +28,7 @@ async def to_code(config):
if operating_mode_config := config.get(CONF_OPERATING_MODE):
sel = await select.new_select(
operating_mode_config,
options=[CONF_SELECTS],
options=CONF_SELECTS,
)
await cg.register_parented(sel, config[CONF_LD2420_ID])
cg.add(LD2420_component.set_operating_mode_select(sel))
+6 -4
View File
@@ -44,11 +44,13 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_DATA, []):
await automation.build_callback_automation(
var, "add_on_data_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+1 -1
View File
@@ -41,7 +41,7 @@ def _lookup_board_pins(board):
board_pins = component.board_pins.get(board, {})
# Resolve aliased board pins (shorthand when two boards have the same pin configuration)
while isinstance(board_pins, str):
board_pins = board_pins[board_pins]
board_pins = component.board_pins[board_pins]
return board_pins
+1 -1
View File
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LIGHTWAVERFComponent),
cv.Optional(CONF_READ_PIN, default=13): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_WRITE_PIN, default=14): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_WRITE_PIN, default=14): pins.internal_gpio_output_pin_schema,
}
).extend(cv.polling_component_schema("1s"))
+15 -12
View File
@@ -81,20 +81,23 @@ def lock_schema(
return _LOCK_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_LOCK,
"add_on_state_callback",
forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED),
),
automation.CallbackAutomation(
CONF_ON_UNLOCK,
"add_on_state_callback",
forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED),
),
)
@setup_entity("lock")
async def _setup_lock_core(var, config):
for conf_key, state_enum in (
(CONF_ON_LOCK, LockState.LOCK_STATE_LOCKED),
(CONF_ON_UNLOCK, LockState.LOCK_STATE_UNLOCKED),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var,
"add_on_state_callback",
[],
conf,
forwarder=LockStateForwarder.template(state_enum),
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if mqtt_id := config.get(CONF_MQTT_ID):
mqtt_ = cg.new_Pvariable(mqtt_id, var)
+11 -8
View File
@@ -211,6 +211,16 @@ CONFIG_SCHEMA = cv.All(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_PS_HIGH_THRESHOLD, "add_on_ps_high_trigger_callback"
),
automation.CallbackAutomation(
CONF_ON_PS_LOW_THRESHOLD, "add_on_ps_low_trigger_callback"
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -240,14 +250,7 @@ async def to_code(config):
sens = await sensor.new_sensor(prox_cnt_config)
cg.add(var.set_proximity_counts_sensor(sens))
for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []):
await automation.build_callback_automation(
var, "add_on_ps_high_trigger_callback", [], conf
)
for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []):
await automation.build_callback_automation(
var, "add_on_ps_low_trigger_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
cg.add(var.set_ltr_type(config[CONF_TYPE]))
+11 -8
View File
@@ -201,6 +201,16 @@ CONFIG_SCHEMA = cv.All(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_PS_HIGH_THRESHOLD, "add_on_ps_high_trigger_callback"
),
automation.CallbackAutomation(
CONF_ON_PS_LOW_THRESHOLD, "add_on_ps_low_trigger_callback"
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -230,14 +240,7 @@ async def to_code(config):
sens = await sensor.new_sensor(prox_cnt_config)
cg.add(var.set_proximity_counts_sensor(sens))
for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []):
await automation.build_callback_automation(
var, "add_on_ps_high_trigger_callback", [], conf
)
for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []):
await automation.build_callback_automation(
var, "add_on_ps_low_trigger_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
cg.add(var.set_ltr_type(config[CONF_TYPE]))
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = (
.extend(
{
cv.GenerateID(CONF_MCP3008_ID): cv.use_id(MCP3008),
cv.Required(CONF_NUMBER): cv.int_,
cv.Required(CONF_NUMBER): cv.int_range(min=0, max=7),
cv.Optional(CONF_REFERENCE_VOLTAGE, default="3.3V"): cv.voltage,
}
)
@@ -48,11 +48,11 @@ async def to_code(config):
config[CONF_CHANNEL],
)
if not config[CONF_TERMINAL_A]:
cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "a"))
cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("a")))
if not config[CONF_TERMINAL_B]:
cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "b"))
cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("b")))
if not config[CONF_TERMINAL_W]:
cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], "w"))
cg.add(parent.initialize_terminal_disabled(config[CONF_CHANNEL], ord("w")))
if CONF_INITIAL_VALUE in config:
cg.add(
parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE])
+48 -11
View File
@@ -69,7 +69,7 @@ StateEnterForwarder = media_player_ns.class_("StateEnterForwarder")
MediaPlayerState = media_player_ns.enum("MediaPlayerState")
# State triggers: (config_key, state enum or None for any-state)
_STATE_TRIGGERS = [
_STATE_TRIGGERS = (
(CONF_ON_STATE, None),
(CONF_ON_IDLE, MediaPlayerState.MEDIA_PLAYER_STATE_IDLE),
(CONF_ON_PLAY, MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING),
@@ -77,7 +77,7 @@ _STATE_TRIGGERS = [
(CONF_ON_ANNOUNCEMENT, MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING),
(CONF_ON_TURN_ON, MediaPlayerState.MEDIA_PLAYER_STATE_ON),
(CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF),
]
)
# State conditions that all share the same schema and codegen handler
_STATE_CONDITIONS = [
@@ -102,17 +102,54 @@ VolumeSetAction = media_player_ns.class_(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder
),
automation.CallbackAutomation(
CONF_ON_IDLE,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
MediaPlayerState.MEDIA_PLAYER_STATE_IDLE
),
),
automation.CallbackAutomation(
CONF_ON_PLAY,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING
),
),
automation.CallbackAutomation(
CONF_ON_PAUSE,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED
),
),
automation.CallbackAutomation(
CONF_ON_ANNOUNCEMENT,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(
MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING
),
),
automation.CallbackAutomation(
CONF_ON_TURN_ON,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_ON),
),
automation.CallbackAutomation(
CONF_ON_TURN_OFF,
"add_on_state_callback",
forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_OFF),
),
)
@setup_entity("media_player")
async def setup_media_player_core_(var, config):
for conf_key, state_enum in _STATE_TRIGGERS:
for conf in config.get(conf_key, []):
if state_enum is None:
forwarder = StateAnyForwarder
else:
forwarder = StateEnterForwarder.template(state_enum)
await automation.build_callback_automation(
var, "add_on_state_callback", [], conf, forwarder=forwarder
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
async def register_media_player(var, config):
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema(
is_polling_component=False,
)
)
.extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range()}),
.extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range(min=0x00, max=0xFF)}),
}
)
@@ -37,8 +37,12 @@ CONFIG_SCHEMA = cv.Schema(
)
.extend(
{
cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range(),
cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range(),
cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range(
min=0x00, max=0xFF
),
cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range(
min=0x00, max=0xFF
),
}
),
}
@@ -205,6 +205,25 @@ async def add_modbus_base_properties(
cg.add(var.set_template(template_))
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_COMMAND_SENT,
"add_on_command_sent_callback",
[(cg.int_, "function_code"), (cg.int_, "address")],
),
automation.CallbackAutomation(
CONF_ON_ONLINE,
"add_on_online_callback",
[(cg.int_, "function_code"), (cg.int_, "address")],
),
automation.CallbackAutomation(
CONF_ON_OFFLINE,
"add_on_offline_callback",
[(cg.int_, "function_code"), (cg.int_, "address")],
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS]))
@@ -257,27 +276,7 @@ async def to_code(config):
)
cg.add(var.add_server_register(server_register_var))
await register_modbus_device(var, config)
for conf in config.get(CONF_ON_COMMAND_SENT, []):
await automation.build_callback_automation(
var,
"add_on_command_sent_callback",
[(cg.int_, "function_code"), (cg.int_, "address")],
conf,
)
for conf in config.get(CONF_ON_ONLINE, []):
await automation.build_callback_automation(
var,
"add_on_online_callback",
[(cg.int_, "function_code"), (cg.int_, "address")],
conf,
)
for conf in config.get(CONF_ON_OFFLINE, []):
await automation.build_callback_automation(
var,
"add_on_offline_callback",
[(cg.int_, "function_code"), (cg.int_, "address")],
conf,
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
async def register_modbus_device(var, config):
+2 -2
View File
@@ -81,9 +81,9 @@ void MY9231OutputComponent::loop() {
}
this->update_ = false;
}
void MY9231OutputComponent::set_channel_value_(uint8_t channel, uint16_t value) {
void MY9231OutputComponent::set_channel_value_(uint16_t channel, uint16_t value) {
ESP_LOGV(TAG, "set channels %u to %u", channel, value);
uint8_t index = this->num_channels_ - channel - 1;
uint16_t index = this->num_channels_ - channel - 1;
if (this->pwm_amounts_[index] != value) {
this->update_ = true;
}
+3 -3
View File
@@ -30,7 +30,7 @@ class MY9231OutputComponent : public Component {
class Channel : public output::FloatOutput {
public:
void set_parent(MY9231OutputComponent *parent) { parent_ = parent; }
void set_channel(uint8_t channel) { channel_ = channel; }
void set_channel(uint16_t channel) { channel_ = channel; }
protected:
void write_state(float state) override {
@@ -39,13 +39,13 @@ class MY9231OutputComponent : public Component {
}
MY9231OutputComponent *parent_;
uint8_t channel_;
uint16_t channel_;
};
protected:
uint16_t get_max_amount_() const { return (uint32_t(1) << this->bit_depth_) - 1; }
void set_channel_value_(uint8_t channel, uint16_t value);
void set_channel_value_(uint16_t channel, uint16_t value);
void init_chips_(uint8_t command);
void write_word_(uint16_t value, uint8_t bits);
void send_di_pulses_(uint8_t count);
+3
View File
@@ -1,5 +1,8 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/string_ref.h"
#include "nextion.h"
namespace esphome::nextion {
@@ -19,6 +19,10 @@ CONF_MAX_COMMANDS_PER_LOOP = "max_commands_per_loop"
CONF_MAX_QUEUE_AGE = "max_queue_age"
CONF_MAX_QUEUE_SIZE = "max_queue_size"
CONF_ON_BUFFER_OVERFLOW = "on_buffer_overflow"
CONF_ON_CUSTOM_BINARY_SENSOR = "on_custom_binary_sensor"
CONF_ON_CUSTOM_SENSOR = "on_custom_sensor"
CONF_ON_CUSTOM_SWITCH = "on_custom_switch"
CONF_ON_CUSTOM_TEXT_SENSOR = "on_custom_text_sensor"
CONF_ON_PAGE = "on_page"
CONF_ON_SETUP = "on_setup"
CONF_ON_SLEEP = "on_sleep"
+64 -31
View File
@@ -20,6 +20,10 @@ from .base_component import (
CONF_MAX_QUEUE_AGE,
CONF_MAX_QUEUE_SIZE,
CONF_ON_BUFFER_OVERFLOW,
CONF_ON_CUSTOM_BINARY_SENSOR,
CONF_ON_CUSTOM_SENSOR,
CONF_ON_CUSTOM_SWITCH,
CONF_ON_CUSTOM_TEXT_SENSOR,
CONF_ON_PAGE,
CONF_ON_SETUP,
CONF_ON_SLEEP,
@@ -88,6 +92,12 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MAX_COMMANDS_PER_LOOP): cv.uint16_t,
cv.Optional(CONF_MAX_QUEUE_SIZE): cv.positive_int,
cv.Optional(CONF_ON_BUFFER_OVERFLOW): automation.validate_automation({}),
cv.Optional(CONF_ON_CUSTOM_BINARY_SENSOR): automation.validate_automation(
{}
),
cv.Optional(CONF_ON_CUSTOM_SENSOR): automation.validate_automation({}),
cv.Optional(CONF_ON_CUSTOM_SWITCH): automation.validate_automation({}),
cv.Optional(CONF_ON_CUSTOM_TEXT_SENSOR): automation.validate_automation({}),
cv.Optional(CONF_ON_PAGE): automation.validate_automation({}),
cv.Optional(CONF_ON_SETUP): automation.validate_automation({}),
cv.Optional(CONF_ON_SLEEP): automation.validate_automation({}),
@@ -144,6 +154,56 @@ async def nextion_set_brightness_to_code(config, action_id, template_arg, args):
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_SETUP, "add_setup_state_callback"),
automation.CallbackAutomation(CONF_ON_SLEEP, "add_sleep_state_callback"),
automation.CallbackAutomation(CONF_ON_WAKE, "add_wake_state_callback"),
automation.CallbackAutomation(
CONF_ON_PAGE, "add_new_page_callback", [(cg.uint8, "x")]
),
automation.CallbackAutomation(
CONF_ON_TOUCH,
"add_touch_event_callback",
[
(cg.uint8, "page_id"),
(cg.uint8, "component_id"),
(cg.bool_, "touch_event"),
],
),
automation.CallbackAutomation(
CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback"
),
automation.CallbackAutomation(
CONF_ON_CUSTOM_BINARY_SENSOR,
"add_custom_binary_sensor_callback",
[(cg.StringRef, "key"), (cg.bool_, "value")],
),
automation.CallbackAutomation(
CONF_ON_CUSTOM_SENSOR,
"add_custom_sensor_callback",
[(cg.StringRef, "key"), (cg.int32, "value")],
),
automation.CallbackAutomation(
CONF_ON_CUSTOM_SWITCH,
"add_custom_switch_callback",
[(cg.StringRef, "key"), (cg.bool_, "value")],
),
automation.CallbackAutomation(
CONF_ON_CUSTOM_TEXT_SENSOR,
"add_custom_text_sensor_callback",
[(cg.StringRef, "key"), (cg.StringRef, "value")],
),
)
# Map custom trigger config keys to their conditional defines
_CUSTOM_TRIGGER_DEFINES = {
CONF_ON_CUSTOM_BINARY_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR",
CONF_ON_CUSTOM_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_SENSOR",
CONF_ON_CUSTOM_SWITCH: "USE_NEXTION_TRIGGER_CUSTOM_SWITCH",
CONF_ON_CUSTOM_TEXT_SENSOR: "USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR",
}
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await uart.register_uart_device(var, config)
@@ -231,35 +291,8 @@ async def to_code(config):
cg.add(var.set_max_commands_per_loop(max_commands_per_loop))
await display.register_display(var, config)
for conf_key, define_name in _CUSTOM_TRIGGER_DEFINES.items():
if config.get(conf_key):
cg.add_define(define_name)
for conf in config.get(CONF_ON_SETUP, []):
await automation.build_callback_automation(
var, "add_setup_state_callback", [], conf
)
for conf in config.get(CONF_ON_SLEEP, []):
await automation.build_callback_automation(
var, "add_sleep_state_callback", [], conf
)
for conf in config.get(CONF_ON_WAKE, []):
await automation.build_callback_automation(
var, "add_wake_state_callback", [], conf
)
for conf in config.get(CONF_ON_PAGE, []):
await automation.build_callback_automation(
var, "add_new_page_callback", [(cg.uint8, "x")], conf
)
for conf in config.get(CONF_ON_TOUCH, []):
await automation.build_callback_automation(
var,
"add_touch_event_callback",
[
(cg.uint8, "page_id"),
(cg.uint8, "component_id"),
(cg.bool_, "touch_event"),
],
conf,
)
for conf in config.get(CONF_ON_BUFFER_OVERFLOW, []):
await automation.build_callback_automation(
var, "add_buffer_overflow_event_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+25 -5
View File
@@ -1,8 +1,11 @@
#include "nextion.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "esphome/core/util.h"
namespace esphome::nextion {
@@ -706,7 +709,7 @@ void Nextion::process_nextion_commands_() {
auto index = to_process.find('\0');
if (index == std::string::npos || (to_process_length - index - 1) < 1) {
ESP_LOGE(TAG, "Bad switch data (0x90)");
ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index);
ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index);
break;
}
@@ -715,6 +718,10 @@ void Nextion::process_nextion_commands_() {
ESP_LOGN(TAG, "Switch %s: %s", ONOFF(to_process[index] != 0), variable_name.c_str());
#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH
this->custom_switch_callback_.call(StringRef(variable_name), to_process[index] != 0);
#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH
for (auto *switchtype : this->switchtype_) {
switchtype->process_bool(variable_name, to_process[index] != 0);
}
@@ -732,7 +739,7 @@ void Nextion::process_nextion_commands_() {
auto index = to_process.find('\0');
if (index == std::string::npos || (to_process_length - index - 1) != 4) {
ESP_LOGE(TAG, "Bad sensor data (0x91)");
ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index);
ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index);
break;
}
@@ -744,6 +751,10 @@ void Nextion::process_nextion_commands_() {
ESP_LOGN(TAG, "Sensor: %s=%d", variable_name.c_str(), value);
#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR
this->custom_sensor_callback_.call(StringRef(variable_name), value);
#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR
for (auto *sensor : this->sensortype_) {
sensor->process_sensor(variable_name, value);
}
@@ -765,7 +776,7 @@ void Nextion::process_nextion_commands_() {
auto index = to_process.find('\0');
if (index == std::string::npos || (to_process_length - index - 1) < 1) {
ESP_LOGE(TAG, "Bad text data (0x92)");
ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index);
ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index);
break;
}
@@ -781,6 +792,11 @@ void Nextion::process_nextion_commands_() {
// nq->variable_name = variable_name;
// nq->state = text_value;
// this->textsensorq_.push_back(nq);
#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
this->custom_text_sensor_callback_.call(StringRef(variable_name), StringRef(text_value));
#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
for (auto *textsensortype : this->textsensortype_) {
textsensortype->process_text(variable_name, text_value);
}
@@ -798,8 +814,8 @@ void Nextion::process_nextion_commands_() {
// Get variable name
auto index = to_process.find('\0');
if (index == std::string::npos || (to_process_length - index - 1) < 1) {
ESP_LOGE(TAG, "Bad binary data (0x92)");
ESP_LOGN(TAG, "proc: %s %zu %d", to_process.c_str(), to_process_length, index);
ESP_LOGE(TAG, "Bad binary data (0x93)");
ESP_LOGN(TAG, "proc: %s %zu %zu", to_process.c_str(), to_process_length, index);
break;
}
@@ -808,6 +824,10 @@ void Nextion::process_nextion_commands_() {
ESP_LOGN(TAG, "Binary sensor: %s=%s", variable_name.c_str(), ONOFF(to_process[index] != 0));
#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
this->custom_binary_sensor_callback_.call(StringRef(variable_name), to_process[index] != 0);
#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
for (auto *binarysensortype : this->binarysensortype_) {
binarysensortype->process_bool(&variable_name[0], to_process[index] != 0);
}
+66
View File
@@ -7,6 +7,7 @@
#include "esphome/components/display/display_color_utils.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/defines.h"
#include "esphome/core/string_ref.h"
#include "esphome/core/time.h"
#ifdef USE_NEXTION_WAVEFORM
@@ -1183,6 +1184,59 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
this->buffer_overflow_callback_.add(std::forward<F>(callback));
}
// Callbacks for Nextion "custom protocol" frames (0x90..0x93)
#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
/** Add a callback to be notified when Nextion sends a custom binary sensor protocol frame (0x93).
*
* This callback is invoked when a Nextion custom binary sensor frame is received,
* providing the component name as the key and the decoded boolean value.
*
* @param callback The void(const StringRef &key, bool value) callback.
*/
template<typename F> void add_custom_binary_sensor_callback(F &&callback) {
this->custom_binary_sensor_callback_.add(std::forward<F>(callback));
}
#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR
/** Add a callback to be notified when Nextion sends a custom sensor protocol frame (0x91).
*
* This callback is invoked when a Nextion custom sensor frame is received,
* providing the component name as the key and the decoded integer value.
*
* @param callback The void(StringRef key, int32_t value) callback.
*/
template<typename F> void add_custom_sensor_callback(F &&callback) {
this->custom_sensor_callback_.add(std::forward<F>(callback));
}
#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR
#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH
/** Add a callback to be notified when Nextion sends a custom switch protocol frame (0x90).
*
* This callback is invoked when a Nextion custom switch frame is received,
* providing the component name as the key and the decoded boolean value.
*
* @param callback The void(const StringRef &key, bool value) callback.
*/
template<typename F> void add_custom_switch_callback(F &&callback) {
this->custom_switch_callback_.add(std::forward<F>(callback));
}
#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH
#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
/** Add a callback to be notified when Nextion sends a custom text sensor protocol frame (0x92).
*
* This callback is invoked when a Nextion custom text sensor frame is received,
* providing the component name as the key and the decoded text value.
*
* @param callback The void(const StringRef &key, const StringRef &value) callback.
*/
template<typename F> void add_custom_text_sensor_callback(F &&callback) {
this->custom_text_sensor_callback_.add(std::forward<F>(callback));
}
#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
void update_all_components();
/**
@@ -1535,6 +1589,18 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
CallbackManager<void(uint8_t)> page_callback_{};
CallbackManager<void(uint8_t, uint8_t, bool)> touch_callback_{};
CallbackManager<void()> buffer_overflow_callback_{};
#ifdef USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
CallbackManager<void(StringRef, bool)> custom_binary_sensor_callback_{};
#endif // USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
#ifdef USE_NEXTION_TRIGGER_CUSTOM_SENSOR
CallbackManager<void(StringRef, int32_t)> custom_sensor_callback_{};
#endif // USE_NEXTION_TRIGGER_CUSTOM_SENSOR
#ifdef USE_NEXTION_TRIGGER_CUSTOM_SWITCH
CallbackManager<void(StringRef, bool)> custom_switch_callback_{};
#endif // USE_NEXTION_TRIGGER_CUSTOM_SWITCH
#ifdef USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
CallbackManager<void(StringRef, StringRef)> custom_text_sensor_callback_{};
#endif // USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
nextion_writer_t writer_;
optional<float> brightness_;
+8 -4
View File
@@ -243,12 +243,16 @@ def number_schema(
return _NUMBER_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_VALUE, "add_on_state_callback", [(float, "x")]
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_number_automations(var, config):
for conf in config.get(CONF_ON_VALUE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(float, "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
for conf in config.get(CONF_ON_VALUE_RANGE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await cg.register_component(trigger, conf)
+9 -9
View File
@@ -105,6 +105,14 @@ async def online_image_action_to_code(config, action_id, template_arg, args):
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
),
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
)
async def to_code(config):
# Use the enhanced helper function to get all runtime image parameters
settings = await runtime_image.process_runtime_image_config(config)
@@ -139,12 +147,4 @@ async def to_code(config):
else:
cg.add(var.add_request_header(key, value))
for conf in config.get(CONF_ON_DOWNLOAD_FINISHED, []):
await automation.build_callback_automation(
var, "add_on_finished_callback", [(bool, "cached")], conf
)
for conf in config.get(CONF_ON_ERROR, []):
await automation.build_callback_automation(
var, "add_on_error_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+1 -1
View File
@@ -51,7 +51,7 @@ PCA6416A_PIN_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin),
cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent),
cv.Required(CONF_NUMBER): cv.int_range(min=0, max=16),
cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15),
cv.Optional(CONF_MODE, default={}): cv.All(
{
cv.Optional(CONF_INPUT, default=False): cv.boolean,
+1 -1
View File
@@ -21,7 +21,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(pcf8563Component),
}
).extend(i2c.i2c_device_schema(0xA3))
).extend(i2c.i2c_device_schema(0x51))
@automation.register_action(
+1 -1
View File
@@ -55,7 +55,7 @@ def validate_mode(value):
PCF8574_PIN_SCHEMA = pins.gpio_base_schema(
PCF8574GPIOPin,
cv.int_range(min=0, max=17),
cv.int_range(min=0, max=15),
modes=[CONF_INPUT, CONF_OUTPUT],
mode_validator=validate_mode,
invertible=True,
+8 -4
View File
@@ -49,6 +49,13 @@ def CONFIG_SCHEMA(conf):
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback"
),
)
async def setup_pn532(var, config):
await cg.register_component(var, config)
@@ -66,10 +73,7 @@ async def setup_pn532(var, config):
trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf
)
for conf in config.get(CONF_ON_FINISHED_WRITE, []):
await automation.build_callback_automation(
var, "add_on_finished_write_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_condition(
+11 -9
View File
@@ -164,6 +164,16 @@ async def pn7150_simple_action_to_code(config, action_id, template_arg, args):
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback"
),
automation.CallbackAutomation(
CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback"
),
)
async def setup_pn7150(var, config):
await cg.register_component(var, config)
@@ -194,15 +204,7 @@ async def setup_pn7150(var, config):
trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf
)
for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []):
await automation.build_callback_automation(
var, "add_on_emulated_tag_scan_callback", [], conf
)
for conf in config.get(CONF_ON_FINISHED_WRITE, []):
await automation.build_callback_automation(
var, "add_on_finished_write_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_condition(
+11 -9
View File
@@ -168,6 +168,16 @@ async def pn7160_simple_action_to_code(config, action_id, template_arg, args):
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback"
),
automation.CallbackAutomation(
CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback"
),
)
async def setup_pn7160(var, config):
await cg.register_component(var, config)
@@ -206,15 +216,7 @@ async def setup_pn7160(var, config):
trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf
)
for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []):
await automation.build_callback_automation(
var, "add_on_emulated_tag_scan_callback", [], conf
)
for conf in config.get(CONF_ON_FINISHED_WRITE, []):
await automation.build_callback_automation(
var, "add_on_finished_write_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_condition(
+15 -11
View File
@@ -67,22 +67,26 @@ CONFIG_SCHEMA = cv.All(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_CODE_RECEIVED,
"add_on_code_received_callback",
[(RFBridgeData, "data")],
),
automation.CallbackAutomation(
CONF_ON_ADVANCED_CODE_RECEIVED,
"add_on_advanced_code_received_callback",
[(RFBridgeAdvancedData, "data")],
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_CODE_RECEIVED, []):
await automation.build_callback_automation(
var, "add_on_code_received_callback", [(RFBridgeData, "data")], conf
)
for conf in config.get(CONF_ON_ADVANCED_CODE_RECEIVED, []):
await automation.build_callback_automation(
var,
"add_on_advanced_code_received_callback",
[(RFBridgeAdvancedData, "data")],
conf,
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema(
+10 -9
View File
@@ -84,6 +84,14 @@ CONFIG_SCHEMA = cv.All(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_CLOCKWISE, "add_on_clockwise_callback"),
automation.CallbackAutomation(
CONF_ON_ANTICLOCKWISE, "add_on_anticlockwise_callback"
),
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
@@ -104,14 +112,7 @@ async def to_code(config):
if CONF_MAX_VALUE in config:
cg.add(var.set_max_value(config[CONF_MAX_VALUE]))
for conf in config.get(CONF_ON_CLOCKWISE, []):
await automation.build_callback_automation(
var, "add_on_clockwise_callback", [], conf
)
for conf in config.get(CONF_ON_ANTICLOCKWISE, []):
await automation.build_callback_automation(
var, "add_on_anticlockwise_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_action(
@@ -119,7 +120,7 @@ async def to_code(config):
RotaryEncoderSetValueAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(sensor.Sensor),
cv.Required(CONF_ID): cv.use_id(RotaryEncoderSensor),
cv.Required(CONF_VALUE): cv.templatable(cv.int_),
}
),
+8 -1
View File
@@ -1,6 +1,7 @@
import platform
import esphome.codegen as cg
import esphome.config_validation as cv
DEPENDENCIES = ["rp2040"]
@@ -31,7 +32,13 @@ async def to_code(config):
# "earlephilhower/tool-pioasm-rp2040-earlephilhower",
# ],
# )
file = PIOASM_DOWNLOADS[platform.system().lower()][platform.machine().lower()]
os_name = platform.system().lower()
arch = platform.machine().lower()
if os_name not in PIOASM_DOWNLOADS or arch not in PIOASM_DOWNLOADS[os_name]:
raise cv.Invalid(
f"pioasm is not available for {platform.system()} {platform.machine()}"
)
file = PIOASM_DOWNLOADS[os_name][arch]
cg.add_platformio_option(
"platform_packages",
[f"earlephilhower/tool-pioasm-rp2040-earlephilhower@{PIOASM_REPO_BASE}/{file}"],
@@ -148,7 +148,6 @@ CHIPSETS = {
"WS2812B": Chipset.CHIPSET_WS2812B,
"SK6812": Chipset.CHIPSET_SK6812,
"SM16703": Chipset.CHIPSET_SM16703,
"CUSTOM": Chipset.CHIPSET_CUSTOM,
}
+8 -4
View File
@@ -71,6 +71,13 @@ FINAL_VALIDATE_SCHEMA = cv.Schema(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_FINISHED_PLAYBACK, "add_on_finished_playback_callback"
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -86,10 +93,7 @@ async def to_code(config):
cg.add(var.set_gain(config[CONF_GAIN]))
for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []):
await automation.build_callback_automation(
var, "add_on_finished_playback_callback", [], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_action(
+9 -5
View File
@@ -65,18 +65,22 @@ async def safe_mode_mark_successful_to_code(config, action_id, template_arg, arg
return var
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_SAFE_MODE, "add_on_safe_mode_callback"),
)
@coroutine_with_priority(CoroPriority.APPLICATION)
async def to_code(config):
if not config[CONF_DISABLED]:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if on_safe_mode_config := config.get(CONF_ON_SAFE_MODE):
if config.get(CONF_ON_SAFE_MODE):
cg.add_define("USE_SAFE_MODE_CALLBACK")
for conf in on_safe_mode_config:
await automation.build_callback_automation(
var, "add_on_safe_mode_callback", [], conf
)
await automation.build_callback_automations(
var, config, _CALLBACK_AUTOMATIONS
)
condition = var.should_enter_safe_mode(
config[CONF_NUM_ATTEMPTS],
+4 -1
View File
@@ -14,7 +14,10 @@ CODEOWNERS = ["@Azimath"]
sdp3x_ns = cg.esphome_ns.namespace("sdp3x")
SDP3XComponent = sdp3x_ns.class_(
"SDP3XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice
"SDP3XComponent",
sensor.Sensor,
cg.PollingComponent,
sensirion_common.SensirionI2CDevice,
)
@@ -33,6 +33,7 @@ CONFIG_SCHEMA = (
# This authentication mode requires that the device must have transmit and receive functionality, a parity mode of "NONE", and a stop bit of one.
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"seeed_mr24hpc1",
baud_rate=115200,
require_tx=True,
require_rx=True,
parity="NONE",
@@ -62,8 +62,6 @@ void MR24HPC1Component::dump_config() {
// Initialisation functions
void MR24HPC1Component::setup() {
this->check_uart_settings(115200);
#ifdef USE_NUMBER
if (this->custom_mode_number_ != nullptr) {
this->custom_mode_number_->publish_state(0); // Zero out the custom mode
+1 -1
View File
@@ -12,7 +12,7 @@ DEPENDENCIES = ["i2c"]
sen0321_sensor_ns = cg.esphome_ns.namespace("sen0321_sensor")
Sen0321Sensor = sen0321_sensor_ns.class_(
"Sen0321Sensor", cg.PollingComponent, i2c.I2CDevice
"Sen0321Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
+1 -1
View File
@@ -8,7 +8,7 @@ DEPENDENCIES = ["i2c"]
sen21231_sensor_ns = cg.esphome_ns.namespace("sen21231_sensor")
Sen21231Sensor = sen21231_sensor_ns.class_(
"Sen21231Sensor", cg.PollingComponent, i2c.I2CDevice
"Sen21231Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
+11 -8
View File
@@ -892,16 +892,19 @@ async def build_filters(config):
return await cg.build_registry_list(FILTER_REGISTRY, config)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_VALUE, "add_on_state_callback", [(float, "x")]
),
automation.CallbackAutomation(
CONF_ON_RAW_VALUE, "add_on_raw_state_callback", [(float, "x")]
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_sensor_automations(var, config):
for conf_key, callback in (
(CONF_ON_VALUE, "add_on_state_callback"),
(CONF_ON_RAW_VALUE, "add_on_raw_state_callback"),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, callback, [(float, "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
for conf in config.get(CONF_ON_VALUE_RANGE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await cg.register_component(trigger, conf)
+26 -23
View File
@@ -48,34 +48,37 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_SMS_RECEIVED,
"add_on_sms_received_callback",
[(cg.std_string, "message"), (cg.std_string, "sender")],
),
automation.CallbackAutomation(
CONF_ON_INCOMING_CALL,
"add_on_incoming_call_callback",
[(cg.std_string, "caller_id")],
),
automation.CallbackAutomation(
CONF_ON_CALL_CONNECTED, "add_on_call_connected_callback"
),
automation.CallbackAutomation(
CONF_ON_CALL_DISCONNECTED, "add_on_call_disconnected_callback"
),
automation.CallbackAutomation(
CONF_ON_USSD_RECEIVED,
"add_on_ussd_received_callback",
[(cg.std_string, "ussd")],
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_SMS_RECEIVED, []):
await automation.build_callback_automation(
var,
"add_on_sms_received_callback",
[(cg.std_string, "message"), (cg.std_string, "sender")],
conf,
)
for conf in config.get(CONF_ON_INCOMING_CALL, []):
await automation.build_callback_automation(
var, "add_on_incoming_call_callback", [(cg.std_string, "caller_id")], conf
)
for conf in config.get(CONF_ON_CALL_CONNECTED, []):
await automation.build_callback_automation(
var, "add_on_call_connected_callback", [], conf
)
for conf in config.get(CONF_ON_CALL_DISCONNECTED, []):
await automation.build_callback_automation(
var, "add_on_call_disconnected_callback", [], conf
)
for conf in config.get(CONF_ON_USSD_RECEIVED, []):
await automation.build_callback_automation(
var, "add_on_ussd_received_callback", [(cg.std_string, "ussd")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
SIM800L_SEND_SMS_SCHEMA = cv.Schema(
+1 -1
View File
@@ -14,7 +14,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_SM16716_ID): cv.use_id(SM16716),
cv.Required(CONF_ID): cv.declare_id(Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=254),
}
).extend(cv.COMPONENT_SCHEMA)
+1 -1
View File
@@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_SM2135_ID): cv.use_id(SM2135),
cv.Required(CONF_ID): cv.declare_id(Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4),
}
).extend(cv.COMPONENT_SCHEMA)
+1 -1
View File
@@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_SM2235_ID): cv.use_id(SM2235),
cv.Required(CONF_ID): cv.declare_id(Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4),
}
).extend(cv.COMPONENT_SCHEMA)
+1 -1
View File
@@ -15,7 +15,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_SM2335_ID): cv.use_id(SM2335),
cv.Required(CONF_ID): cv.declare_id(Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4),
}
).extend(cv.COMPONENT_SCHEMA)
+16 -13
View File
@@ -31,23 +31,26 @@ CONFIG_SCHEMA = (
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DATA,
"add_on_data_callback",
[
(
cg.std_vector.template(cg.uint8).operator("ref").operator("const"),
"bytes",
),
(cg.bool_, "valid"),
],
),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for conf in config.get(CONF_ON_DATA, []):
await automation.build_callback_automation(
var,
"add_on_data_callback",
[
(
cg.std_vector.template(cg.uint8).operator("ref").operator("const"),
"bytes",
),
(cg.bool_, "valid"),
],
conf,
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
def obis_code(value):
+18 -9
View File
@@ -121,17 +121,26 @@ def switch_schema(
return _SWITCH_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", [(bool, "x")]
),
automation.CallbackAutomation(
CONF_ON_TURN_ON,
"add_on_state_callback",
forwarder=automation.TriggerOnTrueForwarder,
),
automation.CallbackAutomation(
CONF_ON_TURN_OFF,
"add_on_state_callback",
forwarder=automation.TriggerOnFalseForwarder,
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_switch_automations(var, config):
for conf_key, args, forwarder in (
(CONF_ON_STATE, [(bool, "x")], None),
(CONF_ON_TURN_ON, [], automation.TriggerOnTrueForwarder),
(CONF_ON_TURN_OFF, [], automation.TriggerOnFalseForwarder),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, "add_on_state_callback", args, conf, forwarder=forwarder
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@setup_entity("switch")
+3 -1
View File
@@ -11,7 +11,9 @@ CODEOWNERS = ["@sethgirvan"]
DEPENDENCIES = ["i2c"]
tc74_ns = cg.esphome_ns.namespace("tc74")
TC74Component = tc74_ns.class_("TC74Component", cg.PollingComponent, i2c.I2CDevice)
TC74Component = tc74_ns.class_(
"TC74Component", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
sensor.sensor_schema(
+11 -8
View File
@@ -184,16 +184,19 @@ async def build_filters(config):
return await cg.build_registry_list(FILTER_REGISTRY, config)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_VALUE, "add_on_state_callback", [(cg.std_string, "x")]
),
automation.CallbackAutomation(
CONF_ON_RAW_VALUE, "add_on_raw_state_callback", [(cg.std_string, "x")]
),
)
@coroutine_with_priority(CoroPriority.AUTOMATION)
async def _build_text_sensor_automations(var, config):
for conf_key, callback in (
(CONF_ON_VALUE, "add_on_state_callback"),
(CONF_ON_RAW_VALUE, "add_on_raw_state_callback"),
):
for conf in config.get(conf_key, []):
await automation.build_callback_automation(
var, callback, [(cg.std_string, "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@setup_entity("text_sensor")
@@ -16,7 +16,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_TLC5947_ID): cv.use_id(TLC5947),
cv.Required(CONF_ID): cv.declare_id(TLC5947Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.uint16_t,
}
).extend(cv.COMPONENT_SCHEMA)
@@ -11,11 +11,11 @@ namespace tlc5947 {
class TLC5947Channel : public output::FloatOutput, public Parented<TLC5947> {
public:
void set_channel(uint8_t channel) { this->channel_ = channel; }
void set_channel(uint16_t channel) { this->channel_ = channel; }
protected:
void write_state(float state) override;
uint8_t channel_;
uint16_t channel_;
};
} // namespace tlc5947
@@ -16,7 +16,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_TLC5971_ID): cv.use_id(TLC5971),
cv.Required(CONF_ID): cv.declare_id(TLC5971Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=65535),
cv.Required(CONF_CHANNEL): cv.uint16_t,
}
).extend(cv.COMPONENT_SCHEMA)
@@ -11,11 +11,11 @@ namespace tlc5971 {
class TLC5971Channel : public output::FloatOutput, public Parented<TLC5971> {
public:
void set_channel(uint8_t channel) { this->channel_ = channel; }
void set_channel(uint16_t channel) { this->channel_ = channel; }
protected:
void write_state(float state) override;
uint8_t channel_;
uint16_t channel_;
};
} // namespace tlc5971
+4 -6
View File
@@ -130,12 +130,9 @@ async def to_code(config):
if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255":
cg.add(var.set_listen_address(listen_address))
cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]]))
if on_receive := config.get(CONF_ON_RECEIVE):
on_receive = on_receive[0]
trigger_id = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID])
trigger = await automation.build_automation(
trigger_id, trigger_argtype, on_receive
)
for conf in config.get(CONF_ON_RECEIVE, []):
trigger_id = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
trigger = await automation.build_automation(trigger_id, trigger_argtype, conf)
trigger_lambda = await cg.process_lambda(
trigger.trigger(
cg.std_vector.template(cg.uint8)(
@@ -146,6 +143,7 @@ async def to_code(config):
listener_argtype,
)
cg.add(var.add_listener(trigger_lambda))
if config.get(CONF_ON_RECEIVE):
cg.add(var.set_should_listen())
+4 -2
View File
@@ -71,9 +71,11 @@ def _validate_load_certificate(value):
def validate_certificate(value):
# _validate_load_certificate already calls cv.file_() internally,
# but returns the parsed certificate object. We re-call cv.file_()
# to get the resolved path string that the bundle walker can discover.
_validate_load_certificate(value)
# Validation result should be the path, not the loaded certificate
return value
return str(cv.file_(value))
def _validate_load_private_key(key, cert_pw):
@@ -1,3 +1,4 @@
from esphome import core
import esphome.codegen as cg
from esphome.components import binary_sensor, esp32_ble_tracker
import esphome.config_validation as cv
@@ -21,9 +22,10 @@ CONFIG_SCHEMA = cv.All(
.extend(
{
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(
CONF_TIMEOUT, default="5s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_TIMEOUT, default="5s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=core.TimePeriod(milliseconds=65535)),
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
+48 -17
View File
@@ -1468,17 +1468,53 @@ hex_uint64_t = hex_int_range(min=0, max=18446744073709551615)
i2c_address = hex_uint8_t
def percentage(value):
def percentage(value: object) -> float:
"""Validate that the value is a percentage.
The resulting value is an integer in the range 0.0 to 1.0.
The resulting value is a float in the range 0.0 to 1.0.
"""
value = possibly_negative_percentage(value)
value = _parse_percentage(value)
return zero_to_one_float(value)
def possibly_negative_percentage(value):
has_percent_sign = False
def possibly_negative_percentage(value: object) -> float:
"""Validate that the value is a possibly negative percentage.
The resulting value is a float in the range -1.0 to 1.0.
"""
value = _parse_percentage(value)
return negative_one_to_one_float(value)
def unbounded_percentage(value: object) -> float:
"""Validate that the value is a percentage, allowing values above 100%.
The resulting value is a non-negative float with no upper bound.
For example, "150%" returns 1.5 and "50%" returns 0.5.
"""
value = _parse_percentage(value)
if value < 0:
raise Invalid("Percentage must not be negative")
return value
def unbounded_possibly_negative_percentage(value: object) -> float:
"""Validate that the value is a possibly negative percentage without bounds.
The resulting value is an unbounded float.
For example, "200%" returns 2.0 and "-150%" returns -1.5.
"""
return _parse_percentage(value)
def _parse_percentage(value: object) -> float:
"""Parse a percentage string or number into a float.
Handles both "50%" style strings and raw float values.
Values without a percent sign above 1.0 or below -1.0 are rejected
to prevent user mistakes (e.g. writing 50 instead of 50%).
"""
has_percent_sign: bool = False
if isinstance(value, str):
try:
if value.endswith("%"):
@@ -1490,21 +1526,16 @@ def possibly_negative_percentage(value):
# pylint: disable=raise-missing-from
raise Invalid("invalid number")
try:
if value > 1:
msg = "Percentage must not be higher than 100%."
if not has_percent_sign:
msg += " Please put a percent sign after the number!"
raise Invalid(msg)
if value < -1:
msg = "Percentage must not be smaller than -100%."
if not has_percent_sign:
msg += " Please put a percent sign after the number!"
raise Invalid(msg)
if not has_percent_sign and (value > 1 or value < -1):
raise Invalid(
"Percentage value must use a percent sign for values "
"outside -1.0 to 1.0. Please put a percent sign after the number!"
)
except TypeError:
raise Invalid( # pylint: disable=raise-missing-from
"Expected percentage or float between -1.0 and 1.0"
"Expected percentage or float"
)
return negative_one_to_one_float(value)
return float(value)
def percentage_int(value):
+4
View File
@@ -123,6 +123,10 @@
#define USE_NEXTION_MAX_COMMANDS_PER_LOOP
#define USE_NEXTION_MAX_QUEUE_SIZE
#define USE_NEXTION_TFT_UPLOAD
#define USE_NEXTION_TRIGGER_CUSTOM_BINARY_SENSOR
#define USE_NEXTION_TRIGGER_CUSTOM_SENSOR
#define USE_NEXTION_TRIGGER_CUSTOM_SWITCH
#define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR
#define USE_NEXTION_WAVEFORM
#define USE_NUMBER
#define USE_OUTPUT
+28 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress
import functools
import inspect
from io import BytesIO, TextIOBase, TextIOWrapper
@@ -44,6 +44,27 @@ _LOGGER = logging.getLogger(__name__)
SECRET_YAML = "secrets.yaml"
_SECRET_CACHE = {}
_SECRET_VALUES = {}
# Not thread-safe — config processing is single-threaded today.
_load_listeners: list[Callable[[Path], None]] = []
@contextmanager
def track_yaml_loads() -> Generator[list[Path]]:
"""Context manager that records every file loaded by the YAML loader.
Yields a list that is populated with resolved Path objects for every
file loaded through ``_load_yaml_internal`` while the context is active.
"""
loaded: list[Path] = []
def _on_load(fname: Path) -> None:
loaded.append(Path(fname).resolve())
_load_listeners.append(_on_load)
try:
yield loaded
finally:
_load_listeners.remove(_on_load)
class ESPHomeDataBase:
@@ -466,6 +487,8 @@ def load_yaml(fname: Path, clear_secrets: bool = True) -> Any:
def _load_yaml_internal(fname: Path) -> Any:
"""Load a YAML file."""
for listener in _load_listeners:
listener(fname)
try:
with fname.open(encoding="utf-8") as f_handle:
return parse_yaml(fname, f_handle)
@@ -473,10 +496,10 @@ def _load_yaml_internal(fname: Path) -> Any:
raise EsphomeError(f"Error reading file {fname}: {err}") from err
def parse_yaml(
file_name: Path, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal
) -> Any:
def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any:
"""Parse a YAML file."""
if yaml_loader is None:
yaml_loader = _load_yaml_internal
try:
return _load_yaml_internal_with_type(
ESPHomeLoader, file_name, file_handle, yaml_loader
+1 -1
View File
@@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
# Unit tests
pytest==9.0.2
pytest==9.0.3
pytest-cov==7.1.0
pytest-mock==3.15.1
pytest-asyncio==1.3.0
+25
View File
@@ -0,0 +1,25 @@
button:
- platform: template
name: Send command test
on_press:
- emontx.send_command:
id: test_emontx
command: "v"
emontx:
id: test_emontx
on_json:
- then:
- logger.log: "Got JSON"
on_data:
- then:
- logger.log:
format: "Got data: %s"
args: [data.c_str()]
sensor:
- platform: emontx
name: Power
tag_name: P1
emontx_id: test_emontx
unit_of_measurement: W
@@ -0,0 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
@@ -6,6 +6,11 @@ esphome:
speed: 255
direction: BACKWARD
id: test_motor
- grove_tb6612fng.run:
channel: 0
speed: !lambda "return 200;"
direction: BACKWARD
id: test_motor
- grove_tb6612fng.stop:
channel: 1
id: test_motor
+8
View File
@@ -22,3 +22,11 @@ output:
id: digipot_wiper_4
mcp4461_id: mcp4461_digipot_01
channel: D
- platform: mcp4461
id: digipot_wiper_5
mcp4461_id: mcp4461_digipot_01
channel: A
terminal_a: false
terminal_b: false
terminal_w: false
+26 -1
View File
@@ -286,6 +286,31 @@ display:
on_buffer_overflow:
then:
logger.log: "Nextion reported a buffer overflow!"
on_custom_text_sensor:
then:
- lambda: |-
// key: StringRef, value: StringRef
if (key == "csv") {
// parse value here, or forward to your own component
ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str());
}
on_custom_sensor:
then:
- lambda: |-
// key: StringRef, value: int32_t
if (key == "temperature_raw") {
ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value);
}
on_custom_binary_sensor:
then:
- lambda: |-
if (key == "btn1") {
ESP_LOGD("nextion.btn", "btn1=%s", ONOFF(value));
}
on_custom_switch:
then:
- lambda: |-
ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value));
on_page:
then:
lambda: 'ESP_LOGD("display","Display shows new page %u", x);'
@@ -304,8 +329,8 @@ display:
on_wake:
then:
lambda: 'ESP_LOGD("display","Display woke up");'
update_interval: 5s
start_up_page: 1
startup_override_ms: 10000ms # Wait 10s for display ready
touch_sleep_timeout: 3
update_interval: 5s
wake_up_page: 2
@@ -9,3 +9,4 @@ uart:
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
rx_buffer_size: 2048
@@ -9,3 +9,4 @@ uart:
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
rx_buffer_size: 2048
@@ -10,3 +10,4 @@ uart:
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
rx_buffer_size: 2048
@@ -10,3 +10,4 @@ uart:
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
rx_buffer_size: 2048
@@ -9,3 +9,4 @@ uart:
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
rx_buffer_size: 2048

Some files were not shown because too many files have changed in this diff Show More