1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-09 17:21:57 +00:00

Compare commits

..

19 Commits

Author SHA1 Message Date
J. Nick Koston
5a711e455a some more tests 2026-02-06 16:12:22 +01:00
J. Nick Koston
38b6746807 fixes 2026-02-06 16:11:54 +01:00
J. Nick Koston
1b8153bd46 address bot review comemnts 2026-02-06 16:04:12 +01:00
J. Nick Koston
f660a62deb make it clear its not a security boundray so nobody relies on it 2026-02-06 16:02:24 +01:00
J. Nick Koston
db92aca490 cover 2026-02-06 15:33:23 +01:00
J. Nick Koston
6a26136c34 fixes 2026-02-06 15:23:32 +01:00
J. Nick Koston
ba07f39c05 Add config bundle CLI command for remote compilation support 2026-02-06 15:15:36 +01:00
J. Nick Koston
d00af090eb cover 2026-02-06 15:10:45 +01:00
J. Nick Koston
49e7052562 safe api 2026-02-06 15:08:53 +01:00
J. Nick Koston
805d335a5d tweaks 2026-02-06 15:04:18 +01:00
J. Nick Koston
001901631f cover 2026-02-06 15:02:26 +01:00
J. Nick Koston
0b2a8c9e27 add more tests 2026-02-06 14:59:18 +01:00
J. Nick Koston
ff783fd9fa adjust some things claude missed 2026-02-06 14:59:05 +01:00
J. Nick Koston
b4c707b440 tests 2026-02-06 14:53:30 +01:00
J. Nick Koston
23d96bf196 bundle 2026-02-06 14:35:38 +01:00
J. Nick Koston
51cbb3e6b2 wip 2026-02-06 14:27:32 +01:00
J. Nick Koston
475ece94ac wip 2026-02-06 14:23:36 +01:00
J. Nick Koston
136606a435 wip 2026-02-06 14:20:55 +01:00
J. Nick Koston
024c87a80b bundles 2026-02-06 14:16:23 +01:00
119 changed files with 3443 additions and 2256 deletions

View File

@@ -1 +1 @@
37ec8d5a343c8d0a485fd2118cbdabcbccd7b9bca197e4a392be75087974dced
069fa9526c52f7c580a9ec17c7678d12f142221387e9b561c18f95394d4629a3

View File

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

View File

@@ -12,6 +12,7 @@ from .const import (
CORE_SUBCATEGORY_PATTERNS,
DEMANGLED_PATTERNS,
ESPHOME_COMPONENT_PATTERN,
SECTION_TO_ATTR,
SYMBOL_PATTERNS,
)
from .demangle import batch_demangle
@@ -90,17 +91,6 @@ class ComponentMemory:
bss_size: int = 0 # Uninitialized data (ram only)
symbol_count: int = 0
def add_section_size(self, section_name: str, size: int) -> None:
"""Add size to the appropriate attribute for a section."""
if section_name == ".text":
self.text_size += size
elif section_name == ".rodata":
self.rodata_size += size
elif section_name == ".data":
self.data_size += size
elif section_name == ".bss":
self.bss_size += size
@property
def flash_total(self) -> int:
"""Total flash usage (text + rodata + data)."""
@@ -177,15 +167,12 @@ class MemoryAnalyzer:
self._elf_symbol_names: set[str] = set()
# SDK symbols not in ELF (static/local symbols from closed-source libs)
self._sdk_symbols: list[SDKSymbol] = []
# CSWTCH symbols: list of (name, size, source_file, component)
self._cswtch_symbols: list[tuple[str, int, str, str]] = []
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
self._parse_sections()
self._parse_symbols()
self._categorize_symbols()
self._analyze_cswtch_symbols()
self._analyze_sdk_libraries()
return dict(self.components)
@@ -268,7 +255,8 @@ class MemoryAnalyzer:
comp_mem.symbol_count += 1
# Update the appropriate size attribute based on section
comp_mem.add_section_size(section_name, size)
if attr_name := SECTION_TO_ATTR.get(section_name):
setattr(comp_mem, attr_name, getattr(comp_mem, attr_name) + size)
# Track uncategorized symbols
if component == "other" and size > 0:
@@ -384,277 +372,6 @@ class MemoryAnalyzer:
return "Other Core"
def _find_object_files_dir(self) -> Path | None:
"""Find the directory containing object files for this build.
Returns:
Path to the directory containing .o files, or None if not found.
"""
# The ELF is typically at .pioenvs/<env>/firmware.elf
# Object files are in .pioenvs/<env>/src/ and .pioenvs/<env>/lib*/
pioenvs_dir = self.elf_path.parent
if pioenvs_dir.exists() and any(pioenvs_dir.glob("src/*.o")):
return pioenvs_dir
return None
@staticmethod
def _parse_nm_cswtch_output(
output: str,
base_dir: Path | None,
cswtch_map: dict[str, list[tuple[str, int]]],
) -> None:
"""Parse nm output for CSWTCH symbols and add to cswtch_map.
Handles both ``.o`` files and ``.a`` archives.
nm output formats::
.o files: /path/file.o:hex_addr hex_size type name
.a files: /path/lib.a:member.o:hex_addr hex_size type name
For ``.o`` files, paths are made relative to *base_dir* when possible.
For ``.a`` archives (detected by ``:`` in the file portion), paths are
formatted as ``archive_stem/member.o`` (e.g. ``liblwip2-536-feat/lwip-esp.o``).
Args:
output: Raw stdout from ``nm --print-file-name -S``.
base_dir: Base directory for computing relative paths of ``.o`` files.
Pass ``None`` when scanning archives outside the build tree.
cswtch_map: Dict to populate, mapping ``"CSWTCH$N:size"`` to source list.
"""
for line in output.splitlines():
if "CSWTCH$" not in line:
continue
# Split on last ":" that precedes a hex address.
# For .o: "filepath.o" : "hex_addr hex_size type name"
# For .a: "filepath.a:member.o" : "hex_addr hex_size type name"
parts_after_colon = line.rsplit(":", 1)
if len(parts_after_colon) != 2:
continue
file_path = parts_after_colon[0]
fields = parts_after_colon[1].split()
# fields: [address, size, type, name]
if len(fields) < 4:
continue
sym_name = fields[3]
if not sym_name.startswith("CSWTCH$"):
continue
try:
size = int(fields[1], 16)
except ValueError:
continue
# Determine readable source path
# Use ".a:" to detect archive format (not bare ":" which matches
# Windows drive letters like "C:\...\file.o").
if ".a:" in file_path:
# Archive format: "archive.a:member.o" → "archive_stem/member.o"
archive_part, member = file_path.rsplit(":", 1)
archive_name = Path(archive_part).stem
rel_path = f"{archive_name}/{member}"
elif base_dir is not None:
try:
rel_path = str(Path(file_path).relative_to(base_dir))
except ValueError:
rel_path = file_path
else:
rel_path = file_path
key = f"{sym_name}:{size}"
cswtch_map[key].append((rel_path, size))
def _run_nm_cswtch_scan(
self,
files: list[Path],
base_dir: Path | None,
cswtch_map: dict[str, list[tuple[str, int]]],
) -> None:
"""Run nm on *files* and add any CSWTCH symbols to *cswtch_map*.
Args:
files: Object (``.o``) or archive (``.a``) files to scan.
base_dir: Base directory for relative path computation (see
:meth:`_parse_nm_cswtch_output`).
cswtch_map: Dict to populate with results.
"""
if not self.nm_path or not files:
return
_LOGGER.debug("Scanning %d files for CSWTCH symbols", len(files))
result = run_tool(
[self.nm_path, "--print-file-name", "-S"] + [str(f) for f in files],
timeout=30,
)
if result is None or result.returncode != 0:
_LOGGER.debug(
"nm failed or timed out scanning %d files for CSWTCH symbols",
len(files),
)
return
self._parse_nm_cswtch_output(result.stdout, base_dir, cswtch_map)
def _scan_cswtch_in_sdk_archives(
self, cswtch_map: dict[str, list[tuple[str, int]]]
) -> None:
"""Scan SDK library archives (.a) for CSWTCH symbols.
Prebuilt SDK libraries (e.g. lwip, bearssl) are not compiled from source,
so their CSWTCH symbols only exist inside ``.a`` archives. Results are
merged into *cswtch_map* for keys not already found in ``.o`` files.
The same source file (e.g. ``lwip-esp.o``) often appears in multiple
library variants (``liblwip2-536.a``, ``liblwip2-1460-feat.a``, etc.),
so results are deduplicated by member name.
"""
sdk_dirs = self._find_sdk_library_dirs()
if not sdk_dirs:
return
sdk_archives = sorted(a for sdk_dir in sdk_dirs for a in sdk_dir.glob("*.a"))
sdk_map: dict[str, list[tuple[str, int]]] = defaultdict(list)
self._run_nm_cswtch_scan(sdk_archives, None, sdk_map)
# Merge SDK results, deduplicating by member name.
for key, sources in sdk_map.items():
if key in cswtch_map:
continue
seen: dict[str, tuple[str, int]] = {}
for path, sz in sources:
member = Path(path).name
if member not in seen:
seen[member] = (path, sz)
cswtch_map[key] = list(seen.values())
def _source_file_to_component(self, source_file: str) -> str:
"""Map a source object file path to its component name.
Args:
source_file: Relative path like 'src/esphome/components/wifi/wifi_component.cpp.o'
Returns:
Component name like '[esphome]wifi' or the source file if unknown.
"""
parts = Path(source_file).parts
# ESPHome component: src/esphome/components/<name>/...
if "components" in parts:
idx = parts.index("components")
if idx + 1 < len(parts):
component_name = parts[idx + 1]
if component_name in get_esphome_components():
return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
if component_name in self.external_components:
return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
# ESPHome core: src/esphome/core/... or src/esphome/...
if "core" in parts and "esphome" in parts:
return _COMPONENT_CORE
if "esphome" in parts and "components" not in parts:
return _COMPONENT_CORE
# Framework/library files - return the first path component
# e.g., lib65b/ESPAsyncTCP/... -> lib65b
# FrameworkArduino/... -> FrameworkArduino
return parts[0] if parts else source_file
def _analyze_cswtch_symbols(self) -> None:
"""Analyze CSWTCH (GCC switch table) symbols by tracing to source objects.
CSWTCH symbols are compiler-generated lookup tables for switch statements.
They are local symbols, so the same name can appear in different object files.
This method scans .o files and SDK archives to attribute them to their
source components.
"""
obj_dir = self._find_object_files_dir()
if obj_dir is None:
_LOGGER.debug("No object files directory found, skipping CSWTCH analysis")
return
# Scan build-dir object files for CSWTCH symbols
cswtch_map: dict[str, list[tuple[str, int]]] = defaultdict(list)
self._run_nm_cswtch_scan(sorted(obj_dir.rglob("*.o")), obj_dir, cswtch_map)
# Also scan SDK library archives (.a) for CSWTCH symbols.
# Prebuilt SDK libraries (e.g. lwip, bearssl) are not compiled from source
# so their symbols only exist inside .a archives, not as loose .o files.
self._scan_cswtch_in_sdk_archives(cswtch_map)
if not cswtch_map:
_LOGGER.debug("No CSWTCH symbols found in object files or SDK archives")
return
# Collect CSWTCH symbols from the ELF (already parsed in sections)
# Include section_name for re-attribution of component totals
elf_cswtch = [
(symbol_name, size, section_name)
for section_name, section in self.sections.items()
for symbol_name, size, _ in section.symbols
if symbol_name.startswith("CSWTCH$")
]
_LOGGER.debug(
"Found %d CSWTCH symbols in ELF, %d unique in object files",
len(elf_cswtch),
len(cswtch_map),
)
# Match ELF CSWTCH symbols to source files and re-attribute component totals.
# _categorize_symbols() already ran and put these into "other" since CSWTCH$
# names don't match any component pattern. We move the bytes to the correct
# component based on the object file mapping.
other_mem = self.components.get("other")
for sym_name, size, section_name in elf_cswtch:
key = f"{sym_name}:{size}"
sources = cswtch_map.get(key, [])
if len(sources) == 1:
source_file = sources[0][0]
component = self._source_file_to_component(source_file)
elif len(sources) > 1:
# Ambiguous - multiple object files have same CSWTCH name+size
source_file = "ambiguous"
component = "ambiguous"
_LOGGER.debug(
"Ambiguous CSWTCH %s (%d B) found in %d files: %s",
sym_name,
size,
len(sources),
", ".join(src for src, _ in sources),
)
else:
source_file = "unknown"
component = "unknown"
self._cswtch_symbols.append((sym_name, size, source_file, component))
# Re-attribute from "other" to the correct component
if (
component not in ("other", "unknown", "ambiguous")
and other_mem is not None
):
other_mem.add_section_size(section_name, -size)
if component not in self.components:
self.components[component] = ComponentMemory(component)
self.components[component].add_section_size(section_name, size)
# Sort by size descending
self._cswtch_symbols.sort(key=lambda x: x[1], reverse=True)
total_size = sum(size for _, size, _, _ in self._cswtch_symbols)
_LOGGER.debug(
"CSWTCH analysis: %d symbols, %d bytes total",
len(self._cswtch_symbols),
total_size,
)
def get_unattributed_ram(self) -> tuple[int, int, int]:
"""Get unattributed RAM sizes (SDK/framework overhead).

View File

@@ -184,52 +184,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
)
def _add_cswtch_analysis(self, lines: list[str]) -> None:
"""Add CSWTCH (GCC switch table lookup) analysis section."""
self._add_section_header(lines, "CSWTCH Analysis (GCC Switch Table Lookups)")
total_size = sum(size for _, size, _, _ in self._cswtch_symbols)
lines.append(
f"Total: {len(self._cswtch_symbols)} switch table(s), {total_size:,} B"
)
lines.append("")
# Group by component
by_component: dict[str, list[tuple[str, int, str]]] = defaultdict(list)
for sym_name, size, source_file, component in self._cswtch_symbols:
by_component[component].append((sym_name, size, source_file))
# Sort components by total size descending
sorted_components = sorted(
by_component.items(),
key=lambda x: sum(s[1] for s in x[1]),
reverse=True,
)
for component, symbols in sorted_components:
comp_total = sum(s[1] for s in symbols)
lines.append(f"{component} ({comp_total:,} B, {len(symbols)} tables):")
# Group by source file within component
by_file: dict[str, list[tuple[str, int]]] = defaultdict(list)
for sym_name, size, source_file in symbols:
by_file[source_file].append((sym_name, size))
for source_file, file_symbols in sorted(
by_file.items(),
key=lambda x: sum(s[1] for s in x[1]),
reverse=True,
):
file_total = sum(s[1] for s in file_symbols)
lines.append(
f" {source_file} ({file_total:,} B, {len(file_symbols)} tables)"
)
for sym_name, size in sorted(
file_symbols, key=lambda x: x[1], reverse=True
):
lines.append(f" {size:>6,} B {sym_name}")
lines.append("")
def generate_report(self, detailed: bool = False) -> str:
"""Generate a formatted memory report."""
components = sorted(
@@ -517,10 +471,6 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f" ... and {len(large_ram_syms) - 10} more")
lines.append("")
# CSWTCH (GCC switch table) analysis
if self._cswtch_symbols:
self._add_cswtch_analysis(lines)
lines.append(
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
)

View File

@@ -66,6 +66,15 @@ SECTION_MAPPING = {
),
}
# Section to ComponentMemory attribute mapping
# Maps section names to the attribute name in ComponentMemory dataclass
SECTION_TO_ATTR = {
".text": "text_size",
".rodata": "rodata_size",
".data": "data_size",
".bss": "bss_size",
}
# Component identification rules
# Symbol patterns: patterns found in raw symbol names
SYMBOL_PATTERNS = {
@@ -504,9 +513,7 @@ SYMBOL_PATTERNS = {
"__FUNCTION__$",
"DAYS_IN_MONTH",
"_DAYS_BEFORE_MONTH",
# Note: CSWTCH$ symbols are GCC switch table lookup tables.
# They are attributed to their source object files via _analyze_cswtch_symbols()
# rather than being lumped into libc.
"CSWTCH$",
"dst$",
"sulp",
"_strtol_l", # String to long with locale

699
esphome/bundle.py Normal file
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

View File

@@ -11,7 +11,6 @@
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
Expression,
FlashStringLiteral,
LineComment,
LogStringLiteral,
MockObj,
@@ -88,7 +87,6 @@ from esphome.cpp_types import ( # noqa: F401
size_t,
std_ns,
std_shared_ptr,
std_span,
std_string,
std_string_ref,
std_vector,

View File

@@ -1,15 +1,32 @@
#include "alarm_control_panel_state.h"
#include "esphome/core/progmem.h"
namespace esphome::alarm_control_panel {
// Alarm control panel state strings indexed by AlarmControlPanelState enum (0-9)
PROGMEM_STRING_TABLE(AlarmControlPanelStateStrings, "DISARMED", "ARMED_HOME", "ARMED_AWAY", "ARMED_NIGHT",
"ARMED_VACATION", "ARMED_CUSTOM_BYPASS", "PENDING", "ARMING", "DISARMING", "TRIGGERED", "UNKNOWN");
const LogString *alarm_control_panel_state_to_string(AlarmControlPanelState state) {
return AlarmControlPanelStateStrings::get_log_str(static_cast<uint8_t>(state),
AlarmControlPanelStateStrings::LAST_INDEX);
switch (state) {
case ACP_STATE_DISARMED:
return LOG_STR("DISARMED");
case ACP_STATE_ARMED_HOME:
return LOG_STR("ARMED_HOME");
case ACP_STATE_ARMED_AWAY:
return LOG_STR("ARMED_AWAY");
case ACP_STATE_ARMED_NIGHT:
return LOG_STR("ARMED_NIGHT");
case ACP_STATE_ARMED_VACATION:
return LOG_STR("ARMED_VACATION");
case ACP_STATE_ARMED_CUSTOM_BYPASS:
return LOG_STR("ARMED_CUSTOM_BYPASS");
case ACP_STATE_PENDING:
return LOG_STR("PENDING");
case ACP_STATE_ARMING:
return LOG_STR("ARMING");
case ACP_STATE_DISARMING:
return LOG_STR("DISARMING");
case ACP_STATE_TRIGGERED:
return LOG_STR("TRIGGERED");
default:
return LOG_STR("UNKNOWN");
}
}
} // namespace esphome::alarm_control_panel

View File

@@ -524,24 +524,24 @@ async def homeassistant_service_to_code(
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string)
templ = await cg.templatable(config[CONF_ACTION], args, None)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
templ = await cg.templatable(value, args, cg.std_string)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
templ = await cg.templatable(value, args, None)
cg.add(var.add_data(key, templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, cg.std_string)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
templ = await cg.templatable(value, args, None)
cg.add(var.add_data_template(key, templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, cg.std_string)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(key, templ))
if on_error := config.get(CONF_ON_ERROR):
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
@@ -609,24 +609,24 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args):
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string)
templ = await cg.templatable(config[CONF_EVENT], args, None)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
templ = await cg.templatable(value, args, cg.std_string)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
templ = await cg.templatable(value, args, None)
cg.add(var.add_data(key, templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, cg.std_string)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
templ = await cg.templatable(value, args, None)
cg.add(var.add_data_template(key, templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, cg.std_string)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(key, templ))
return var
@@ -649,11 +649,11 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned")))
cg.add(var.set_service("esphome.tag_scanned"))
# Initialize FixedVector with exact size (1 data field)
cg.add(var.init_data(1))
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
cg.add(var.add_data("tag_id", templ))
return var

View File

@@ -283,7 +283,7 @@ void APIConnection::loop() {
#endif
}
bool APIConnection::send_disconnect_response() {
bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) {
// remote initiated disconnect_client
// don't close yet, we still need to send the disconnect response
// close will happen on next loop
@@ -292,7 +292,7 @@ bool APIConnection::send_disconnect_response() {
DisconnectResponse resp;
return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE);
}
void APIConnection::on_disconnect_response() {
void APIConnection::on_disconnect_response(const DisconnectResponse &value) {
this->helper_->close();
this->flags_.remove = true;
}
@@ -1095,7 +1095,7 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags);
}
void APIConnection::unsubscribe_bluetooth_le_advertisements() {
void APIConnection::unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
}
void APIConnection::bluetooth_device_request(const BluetoothDeviceRequest &msg) {
@@ -1121,7 +1121,8 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg)
bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_notify(msg);
}
bool APIConnection::send_subscribe_bluetooth_connections_free_response() {
bool APIConnection::send_subscribe_bluetooth_connections_free_response(
const SubscribeBluetoothConnectionsFreeRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this);
return true;
}
@@ -1490,12 +1491,12 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) {
return this->send_message(resp, HelloResponse::MESSAGE_TYPE);
}
bool APIConnection::send_ping_response() {
bool APIConnection::send_ping_response(const PingRequest &msg) {
PingResponse resp;
return this->send_message(resp, PingResponse::MESSAGE_TYPE);
}
bool APIConnection::send_device_info_response() {
bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) {
DeviceInfoResponse resp{};
resp.name = StringRef(App.get_name());
resp.friendly_name = StringRef(App.get_friendly_name());
@@ -1745,7 +1746,9 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption
}
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::subscribe_home_assistant_states() { state_subs_at_ = 0; }
void APIConnection::subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) {
state_subs_at_ = 0;
}
#endif
bool APIConnection::try_to_clear_buffer(bool log_out_of_space) {
if (this->flags_.remove)

View File

@@ -127,7 +127,7 @@ class APIConnection final : public APIServerConnection {
#endif // USE_API_HOMEASSISTANT_SERVICES
#ifdef USE_BLUETOOTH_PROXY
void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
void unsubscribe_bluetooth_le_advertisements() override;
void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override;
void bluetooth_device_request(const BluetoothDeviceRequest &msg) override;
void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) override;
@@ -136,7 +136,7 @@ class APIConnection final : public APIServerConnection {
void bluetooth_gatt_write_descriptor(const BluetoothGATTWriteDescriptorRequest &msg) override;
void bluetooth_gatt_get_services(const BluetoothGATTGetServicesRequest &msg) override;
void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) override;
bool send_subscribe_bluetooth_connections_free_response() override;
bool send_subscribe_bluetooth_connections_free_response(const SubscribeBluetoothConnectionsFreeRequest &msg) override;
void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) override;
#endif
@@ -187,8 +187,8 @@ class APIConnection final : public APIServerConnection {
void update_command(const UpdateCommandRequest &msg) override;
#endif
void on_disconnect_response() override;
void on_ping_response() override {
void on_disconnect_response(const DisconnectResponse &value) override;
void on_ping_response(const PingResponse &value) override {
// we initiated ping
this->flags_.sent_ping = false;
}
@@ -199,11 +199,11 @@ class APIConnection final : public APIServerConnection {
void on_get_time_response(const GetTimeResponse &value) override;
#endif
bool send_hello_response(const HelloRequest &msg) override;
bool send_disconnect_response() override;
bool send_ping_response() override;
bool send_device_info_response() override;
void list_entities() override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void subscribe_states() override {
bool send_disconnect_response(const DisconnectRequest &msg) override;
bool send_ping_response(const PingRequest &msg) override;
bool send_device_info_response(const DeviceInfoRequest &msg) override;
void list_entities(const ListEntitiesRequest &msg) override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void subscribe_states(const SubscribeStatesRequest &msg) override {
this->flags_.state_subscription = true;
// Start initial state iterator only if no iterator is active
// If list_entities is running, we'll start initial_state when it completes
@@ -217,10 +217,12 @@ class APIConnection final : public APIServerConnection {
App.schedule_dump_config();
}
#ifdef USE_API_HOMEASSISTANT_SERVICES
void subscribe_homeassistant_services() override { this->flags_.service_call_subscription = true; }
void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override {
this->flags_.service_call_subscription = true;
}
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void subscribe_home_assistant_states() override;
void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override;
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
void execute_service(const ExecuteServiceRequest &msg) override;

View File

@@ -15,9 +15,6 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name, const
DumpBuffer dump_buf;
ESP_LOGVV(TAG, "%s: %s", LOG_STR_ARG(name), msg.dump_to(dump_buf));
}
void APIServerConnectionBase::log_receive_message_(const LogString *name) {
ESP_LOGVV(TAG, "%s: {}", LOG_STR_ARG(name));
}
#endif
void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {
@@ -32,52 +29,66 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
break;
}
case DisconnectRequest::MESSAGE_TYPE: {
DisconnectRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_disconnect_request"));
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
#endif
this->on_disconnect_request();
this->on_disconnect_request(msg);
break;
}
case DisconnectResponse::MESSAGE_TYPE: {
DisconnectResponse msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_disconnect_response"));
this->log_receive_message_(LOG_STR("on_disconnect_response"), msg);
#endif
this->on_disconnect_response();
this->on_disconnect_response(msg);
break;
}
case PingRequest::MESSAGE_TYPE: {
PingRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_ping_request"));
this->log_receive_message_(LOG_STR("on_ping_request"), msg);
#endif
this->on_ping_request();
this->on_ping_request(msg);
break;
}
case PingResponse::MESSAGE_TYPE: {
PingResponse msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_ping_response"));
this->log_receive_message_(LOG_STR("on_ping_response"), msg);
#endif
this->on_ping_response();
this->on_ping_response(msg);
break;
}
case DeviceInfoRequest::MESSAGE_TYPE: {
DeviceInfoRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_info_request"));
this->log_receive_message_(LOG_STR("on_device_info_request"), msg);
#endif
this->on_device_info_request();
this->on_device_info_request(msg);
break;
}
case ListEntitiesRequest::MESSAGE_TYPE: {
ListEntitiesRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_list_entities_request"));
this->log_receive_message_(LOG_STR("on_list_entities_request"), msg);
#endif
this->on_list_entities_request();
this->on_list_entities_request(msg);
break;
}
case SubscribeStatesRequest::MESSAGE_TYPE: {
SubscribeStatesRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_states_request"));
this->log_receive_message_(LOG_STR("on_subscribe_states_request"), msg);
#endif
this->on_subscribe_states_request();
this->on_subscribe_states_request(msg);
break;
}
case SubscribeLogsRequest::MESSAGE_TYPE: {
@@ -135,10 +146,12 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
#endif
#ifdef USE_API_HOMEASSISTANT_SERVICES
case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: {
SubscribeHomeassistantServicesRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request"));
this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request"), msg);
#endif
this->on_subscribe_homeassistant_services_request();
this->on_subscribe_homeassistant_services_request(msg);
break;
}
#endif
@@ -153,10 +166,12 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
}
#ifdef USE_API_HOMEASSISTANT_STATES
case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: {
SubscribeHomeAssistantStatesRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request"));
this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request"), msg);
#endif
this->on_subscribe_home_assistant_states_request();
this->on_subscribe_home_assistant_states_request(msg);
break;
}
#endif
@@ -360,19 +375,23 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
#endif
#ifdef USE_BLUETOOTH_PROXY
case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: {
SubscribeBluetoothConnectionsFreeRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"));
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"), msg);
#endif
this->on_subscribe_bluetooth_connections_free_request();
this->on_subscribe_bluetooth_connections_free_request(msg);
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: {
UnsubscribeBluetoothLEAdvertisementsRequest msg;
// Empty message: no decode needed
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request"));
this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request"), msg);
#endif
this->on_unsubscribe_bluetooth_le_advertisements_request();
this->on_unsubscribe_bluetooth_le_advertisements_request(msg);
break;
}
#endif
@@ -628,29 +647,36 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) {
this->on_fatal_error();
}
}
void APIServerConnection::on_disconnect_request() {
if (!this->send_disconnect_response()) {
void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) {
if (!this->send_disconnect_response(msg)) {
this->on_fatal_error();
}
}
void APIServerConnection::on_ping_request() {
if (!this->send_ping_response()) {
void APIServerConnection::on_ping_request(const PingRequest &msg) {
if (!this->send_ping_response(msg)) {
this->on_fatal_error();
}
}
void APIServerConnection::on_device_info_request() {
if (!this->send_device_info_response()) {
void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) {
if (!this->send_device_info_response(msg)) {
this->on_fatal_error();
}
}
void APIServerConnection::on_list_entities_request() { this->list_entities(); }
void APIServerConnection::on_subscribe_states_request() { this->subscribe_states(); }
void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { this->list_entities(msg); }
void APIServerConnection::on_subscribe_states_request(const SubscribeStatesRequest &msg) {
this->subscribe_states(msg);
}
void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { this->subscribe_logs(msg); }
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServerConnection::on_subscribe_homeassistant_services_request() { this->subscribe_homeassistant_services(); }
void APIServerConnection::on_subscribe_homeassistant_services_request(
const SubscribeHomeassistantServicesRequest &msg) {
this->subscribe_homeassistant_services(msg);
}
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void APIServerConnection::on_subscribe_home_assistant_states_request() { this->subscribe_home_assistant_states(); }
void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) {
this->subscribe_home_assistant_states(msg);
}
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); }
@@ -767,15 +793,17 @@ void APIServerConnection::on_bluetooth_gatt_notify_request(const BluetoothGATTNo
}
#endif
#ifdef USE_BLUETOOTH_PROXY
void APIServerConnection::on_subscribe_bluetooth_connections_free_request() {
if (!this->send_subscribe_bluetooth_connections_free_response()) {
void APIServerConnection::on_subscribe_bluetooth_connections_free_request(
const SubscribeBluetoothConnectionsFreeRequest &msg) {
if (!this->send_subscribe_bluetooth_connections_free_response(msg)) {
this->on_fatal_error();
}
}
#endif
#ifdef USE_BLUETOOTH_PROXY
void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
this->unsubscribe_bluetooth_le_advertisements();
void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request(
const UnsubscribeBluetoothLEAdvertisementsRequest &msg) {
this->unsubscribe_bluetooth_le_advertisements(msg);
}
#endif
#ifdef USE_BLUETOOTH_PROXY

View File

@@ -14,7 +14,6 @@ class APIServerConnectionBase : public ProtoService {
protected:
void log_send_message_(const char *name, const char *dump);
void log_receive_message_(const LogString *name, const ProtoMessage &msg);
void log_receive_message_(const LogString *name);
public:
#endif
@@ -29,15 +28,15 @@ class APIServerConnectionBase : public ProtoService {
virtual void on_hello_request(const HelloRequest &value){};
virtual void on_disconnect_request(){};
virtual void on_disconnect_response(){};
virtual void on_ping_request(){};
virtual void on_ping_response(){};
virtual void on_device_info_request(){};
virtual void on_disconnect_request(const DisconnectRequest &value){};
virtual void on_disconnect_response(const DisconnectResponse &value){};
virtual void on_ping_request(const PingRequest &value){};
virtual void on_ping_response(const PingResponse &value){};
virtual void on_device_info_request(const DeviceInfoRequest &value){};
virtual void on_list_entities_request(){};
virtual void on_list_entities_request(const ListEntitiesRequest &value){};
virtual void on_subscribe_states_request(){};
virtual void on_subscribe_states_request(const SubscribeStatesRequest &value){};
#ifdef USE_COVER
virtual void on_cover_command_request(const CoverCommandRequest &value){};
@@ -62,14 +61,14 @@ class APIServerConnectionBase : public ProtoService {
#endif
#ifdef USE_API_HOMEASSISTANT_SERVICES
virtual void on_subscribe_homeassistant_services_request(){};
virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){};
#endif
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
virtual void on_homeassistant_action_response(const HomeassistantActionResponse &value){};
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
virtual void on_subscribe_home_assistant_states_request(){};
virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){};
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
@@ -148,11 +147,12 @@ class APIServerConnectionBase : public ProtoService {
#endif
#ifdef USE_BLUETOOTH_PROXY
virtual void on_subscribe_bluetooth_connections_free_request(){};
virtual void on_subscribe_bluetooth_connections_free_request(const SubscribeBluetoothConnectionsFreeRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
virtual void on_unsubscribe_bluetooth_le_advertisements_request(){};
virtual void on_unsubscribe_bluetooth_le_advertisements_request(
const UnsubscribeBluetoothLEAdvertisementsRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
@@ -231,17 +231,17 @@ class APIServerConnectionBase : public ProtoService {
class APIServerConnection : public APIServerConnectionBase {
public:
virtual bool send_hello_response(const HelloRequest &msg) = 0;
virtual bool send_disconnect_response() = 0;
virtual bool send_ping_response() = 0;
virtual bool send_device_info_response() = 0;
virtual void list_entities() = 0;
virtual void subscribe_states() = 0;
virtual bool send_disconnect_response(const DisconnectRequest &msg) = 0;
virtual bool send_ping_response(const PingRequest &msg) = 0;
virtual bool send_device_info_response(const DeviceInfoRequest &msg) = 0;
virtual void list_entities(const ListEntitiesRequest &msg) = 0;
virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0;
virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0;
#ifdef USE_API_HOMEASSISTANT_SERVICES
virtual void subscribe_homeassistant_services() = 0;
virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0;
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
virtual void subscribe_home_assistant_states() = 0;
virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0;
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
virtual void execute_service(const ExecuteServiceRequest &msg) = 0;
@@ -331,10 +331,11 @@ class APIServerConnection : public APIServerConnectionBase {
virtual void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) = 0;
#endif
#ifdef USE_BLUETOOTH_PROXY
virtual bool send_subscribe_bluetooth_connections_free_response() = 0;
virtual bool send_subscribe_bluetooth_connections_free_response(
const SubscribeBluetoothConnectionsFreeRequest &msg) = 0;
#endif
#ifdef USE_BLUETOOTH_PROXY
virtual void unsubscribe_bluetooth_le_advertisements() = 0;
virtual void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) = 0;
#endif
#ifdef USE_BLUETOOTH_PROXY
virtual void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) = 0;
@@ -362,17 +363,17 @@ class APIServerConnection : public APIServerConnectionBase {
#endif
protected:
void on_hello_request(const HelloRequest &msg) override;
void on_disconnect_request() override;
void on_ping_request() override;
void on_device_info_request() override;
void on_list_entities_request() override;
void on_subscribe_states_request() override;
void on_disconnect_request(const DisconnectRequest &msg) override;
void on_ping_request(const PingRequest &msg) override;
void on_device_info_request(const DeviceInfoRequest &msg) override;
void on_list_entities_request(const ListEntitiesRequest &msg) override;
void on_subscribe_states_request(const SubscribeStatesRequest &msg) override;
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override;
#ifdef USE_API_HOMEASSISTANT_SERVICES
void on_subscribe_homeassistant_services_request() override;
void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override;
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void on_subscribe_home_assistant_states_request() override;
void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override;
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
void on_execute_service_request(const ExecuteServiceRequest &msg) override;
@@ -462,10 +463,11 @@ class APIServerConnection : public APIServerConnectionBase {
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override;
#endif
#ifdef USE_BLUETOOTH_PROXY
void on_subscribe_bluetooth_connections_free_request() override;
void on_subscribe_bluetooth_connections_free_request(const SubscribeBluetoothConnectionsFreeRequest &msg) override;
#endif
#ifdef USE_BLUETOOTH_PROXY
void on_unsubscribe_bluetooth_le_advertisements_request() override;
void on_unsubscribe_bluetooth_le_advertisements_request(
const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override;
#endif
#ifdef USE_BLUETOOTH_PROXY
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override;

View File

@@ -126,20 +126,6 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
this->add_kv_(this->variables_, key, std::forward<V>(value));
}
#ifdef USE_ESP8266
// On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer).
// Store as const char* — populate_service_map copies from PROGMEM at play() time.
template<typename V> void add_data(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->data_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
template<typename V> void add_data_template(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->data_template_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
template<typename V> void add_variable(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->variables_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
#endif
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
template<typename T> void set_response_template(T response_template) {
this->response_template_ = response_template;
@@ -231,31 +217,7 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
Ts... x) {
dest.init(source.size());
#ifdef USE_ESP8266
// On ESP8266, keys may be in PROGMEM (from ESPHOME_F in codegen) and
// FLASH_STRING values need copying via _P functions.
// Allocate storage for all keys + all values (2 entries per source item).
// strlen_P/memcpy_P handle both RAM and PROGMEM pointers safely.
value_storage.init(source.size() * 2);
for (auto &it : source) {
auto &kv = dest.emplace_back();
// Key: copy from possible PROGMEM
{
size_t key_len = strlen_P(it.key);
value_storage.push_back(std::string(key_len, '\0'));
memcpy_P(value_storage.back().data(), it.key, key_len);
kv.key = StringRef(value_storage.back());
}
// Value: value() handles FLASH_STRING via _P functions internally
value_storage.push_back(it.value.value(x...));
kv.value = StringRef(value_storage.back());
}
#else
// On non-ESP8266, strings are directly readable from flash-mapped memory.
// Count non-static strings to allocate exact storage needed.
// Count non-static strings to allocate exact storage needed
size_t lambda_count = 0;
for (const auto &it : source) {
if (!it.value.is_static_string()) {
@@ -269,15 +231,14 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
kv.key = StringRef(it.key);
if (it.value.is_static_string()) {
// Static string — pointer directly readable, zero allocation
// Static string from YAML - zero allocation
kv.value = StringRef(it.value.get_static_string());
} else {
// Lambda evaluate and store result
// Lambda evaluation - store result, reference it
value_storage.push_back(it.value.value(x...));
kv.value = StringRef(value_storage.back());
}
}
#endif
}
APIServer *parent_;

View File

@@ -112,12 +112,8 @@ class ProtoVarInt {
uint64_t result = buffer[0] & 0x7F;
uint8_t bitpos = 7;
// A 64-bit varint is at most 10 bytes (ceil(64/7)). Reject overlong encodings
// to avoid undefined behavior from shifting uint64_t by >= 64 bits.
uint32_t max_len = std::min(len, uint32_t(10));
// Start from the second byte since we've already processed the first
for (uint32_t i = 1; i < max_len; i++) {
for (uint32_t i = 1; i < len; i++) {
uint8_t val = buffer[i];
result |= uint64_t(val & 0x7F) << uint64_t(bitpos);
bitpos += 7;

View File

@@ -1,44 +1,109 @@
#include "climate_mode.h"
#include "esphome/core/progmem.h"
namespace esphome::climate {
// Climate mode strings indexed by ClimateMode enum (0-6): OFF, HEAT_COOL, COOL, HEAT, FAN_ONLY, DRY, AUTO
PROGMEM_STRING_TABLE(ClimateModeStrings, "OFF", "HEAT_COOL", "COOL", "HEAT", "FAN_ONLY", "DRY", "AUTO", "UNKNOWN");
const LogString *climate_mode_to_string(ClimateMode mode) {
return ClimateModeStrings::get_log_str(static_cast<uint8_t>(mode), ClimateModeStrings::LAST_INDEX);
switch (mode) {
case CLIMATE_MODE_OFF:
return LOG_STR("OFF");
case CLIMATE_MODE_HEAT_COOL:
return LOG_STR("HEAT_COOL");
case CLIMATE_MODE_AUTO:
return LOG_STR("AUTO");
case CLIMATE_MODE_COOL:
return LOG_STR("COOL");
case CLIMATE_MODE_HEAT:
return LOG_STR("HEAT");
case CLIMATE_MODE_FAN_ONLY:
return LOG_STR("FAN_ONLY");
case CLIMATE_MODE_DRY:
return LOG_STR("DRY");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate action strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN", "UNKNOWN");
const LogString *climate_action_to_string(ClimateAction action) {
return ClimateActionStrings::get_log_str(static_cast<uint8_t>(action), ClimateActionStrings::LAST_INDEX);
switch (action) {
case CLIMATE_ACTION_OFF:
return LOG_STR("OFF");
case CLIMATE_ACTION_COOLING:
return LOG_STR("COOLING");
case CLIMATE_ACTION_HEATING:
return LOG_STR("HEATING");
case CLIMATE_ACTION_IDLE:
return LOG_STR("IDLE");
case CLIMATE_ACTION_DRYING:
return LOG_STR("DRYING");
case CLIMATE_ACTION_FAN:
return LOG_STR("FAN");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate fan mode strings indexed by ClimateFanMode enum (0-9): ON, OFF, AUTO, LOW, MEDIUM, HIGH, MIDDLE, FOCUS,
// DIFFUSE, QUIET
PROGMEM_STRING_TABLE(ClimateFanModeStrings, "ON", "OFF", "AUTO", "LOW", "MEDIUM", "HIGH", "MIDDLE", "FOCUS", "DIFFUSE",
"QUIET", "UNKNOWN");
const LogString *climate_fan_mode_to_string(ClimateFanMode fan_mode) {
return ClimateFanModeStrings::get_log_str(static_cast<uint8_t>(fan_mode), ClimateFanModeStrings::LAST_INDEX);
switch (fan_mode) {
case climate::CLIMATE_FAN_ON:
return LOG_STR("ON");
case climate::CLIMATE_FAN_OFF:
return LOG_STR("OFF");
case climate::CLIMATE_FAN_AUTO:
return LOG_STR("AUTO");
case climate::CLIMATE_FAN_LOW:
return LOG_STR("LOW");
case climate::CLIMATE_FAN_MEDIUM:
return LOG_STR("MEDIUM");
case climate::CLIMATE_FAN_HIGH:
return LOG_STR("HIGH");
case climate::CLIMATE_FAN_MIDDLE:
return LOG_STR("MIDDLE");
case climate::CLIMATE_FAN_FOCUS:
return LOG_STR("FOCUS");
case climate::CLIMATE_FAN_DIFFUSE:
return LOG_STR("DIFFUSE");
case climate::CLIMATE_FAN_QUIET:
return LOG_STR("QUIET");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate swing mode strings indexed by ClimateSwingMode enum (0-3): OFF, BOTH, VERTICAL, HORIZONTAL
PROGMEM_STRING_TABLE(ClimateSwingModeStrings, "OFF", "BOTH", "VERTICAL", "HORIZONTAL", "UNKNOWN");
const LogString *climate_swing_mode_to_string(ClimateSwingMode swing_mode) {
return ClimateSwingModeStrings::get_log_str(static_cast<uint8_t>(swing_mode), ClimateSwingModeStrings::LAST_INDEX);
switch (swing_mode) {
case climate::CLIMATE_SWING_OFF:
return LOG_STR("OFF");
case climate::CLIMATE_SWING_BOTH:
return LOG_STR("BOTH");
case climate::CLIMATE_SWING_VERTICAL:
return LOG_STR("VERTICAL");
case climate::CLIMATE_SWING_HORIZONTAL:
return LOG_STR("HORIZONTAL");
default:
return LOG_STR("UNKNOWN");
}
}
// Climate preset strings indexed by ClimatePreset enum (0-7): NONE, HOME, AWAY, BOOST, COMFORT, ECO, SLEEP, ACTIVITY
PROGMEM_STRING_TABLE(ClimatePresetStrings, "NONE", "HOME", "AWAY", "BOOST", "COMFORT", "ECO", "SLEEP", "ACTIVITY",
"UNKNOWN");
const LogString *climate_preset_to_string(ClimatePreset preset) {
return ClimatePresetStrings::get_log_str(static_cast<uint8_t>(preset), ClimatePresetStrings::LAST_INDEX);
switch (preset) {
case climate::CLIMATE_PRESET_NONE:
return LOG_STR("NONE");
case climate::CLIMATE_PRESET_HOME:
return LOG_STR("HOME");
case climate::CLIMATE_PRESET_ECO:
return LOG_STR("ECO");
case climate::CLIMATE_PRESET_AWAY:
return LOG_STR("AWAY");
case climate::CLIMATE_PRESET_BOOST:
return LOG_STR("BOOST");
case climate::CLIMATE_PRESET_COMFORT:
return LOG_STR("COMFORT");
case climate::CLIMATE_PRESET_SLEEP:
return LOG_STR("SLEEP");
case climate::CLIMATE_PRESET_ACTIVITY:
return LOG_STR("ACTIVITY");
default:
return LOG_STR("UNKNOWN");
}
}
} // namespace esphome::climate

View File

@@ -19,11 +19,17 @@ const LogString *cover_command_to_str(float pos) {
return LOG_STR("UNKNOWN");
}
}
// Cover operation strings indexed by CoverOperation enum (0-2): IDLE, OPENING, CLOSING, plus UNKNOWN
PROGMEM_STRING_TABLE(CoverOperationStrings, "IDLE", "OPENING", "CLOSING", "UNKNOWN");
const LogString *cover_operation_to_str(CoverOperation op) {
return CoverOperationStrings::get_log_str(static_cast<uint8_t>(op), CoverOperationStrings::LAST_INDEX);
switch (op) {
case COVER_OPERATION_IDLE:
return LOG_STR("IDLE");
case COVER_OPERATION_OPENING:
return LOG_STR("OPENING");
case COVER_OPERATION_CLOSING:
return LOG_STR("CLOSING");
default:
return LOG_STR("UNKNOWN");
}
}
Cover::Cover() : position{COVER_OPEN} {}

View File

@@ -66,7 +66,7 @@ async def to_code(config):
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
# DSMR Parser
cg.add_library("esphome/dsmr_parser", "1.1.0")
cg.add_library("esphome/dsmr_parser", "1.0.0")
# Crypto
cg.add_library("polargoose/Crypto-no-arduino", "0.4.0")

View File

@@ -718,6 +718,14 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("fw_core_version"): sensor.sensor_schema(
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("fw_module_version"): sensor.sensor_schema(
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
}
).extend(cv.COMPONENT_SCHEMA)

View File

@@ -26,9 +26,7 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional("sub_equipment_id"): text_sensor.text_sensor_schema(),
cv.Optional("gas_delivered_text"): text_sensor.text_sensor_schema(),
cv.Optional("fw_core_checksum"): text_sensor.text_sensor_schema(),
cv.Optional("fw_core_version"): text_sensor.text_sensor_schema(),
cv.Optional("fw_module_checksum"): text_sensor.text_sensor_schema(),
cv.Optional("fw_module_version"): text_sensor.text_sensor_schema(),
cv.Optional("telegram"): text_sensor.text_sensor_schema().extend(
{cv.Optional(CONF_INTERNAL, default=True): cv.boolean}
),

View File

@@ -55,6 +55,7 @@ void E131Component::setup() {
}
void E131Component::loop() {
std::vector<uint8_t> payload;
E131Packet packet;
int universe = 0;
uint8_t buf[1460];
@@ -63,9 +64,11 @@ void E131Component::loop() {
if (len == -1) {
return;
}
payload.resize(len);
memmove(&payload[0], buf, len);
if (!this->packet_(buf, (size_t) len, universe, packet)) {
ESP_LOGV(TAG, "Invalid packet received of size %zd.", len);
if (!this->packet_(payload, universe, packet)) {
ESP_LOGV(TAG, "Invalid packet received of size %zu.", payload.size());
return;
}

View File

@@ -38,7 +38,7 @@ class E131Component : public esphome::Component {
void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; }
protected:
bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet);
bool packet_(const std::vector<uint8_t> &data, int &universe, E131Packet &packet);
bool process_(int universe, const E131Packet &packet);
bool join_igmp_groups_();
void join_(int universe);

View File

@@ -116,11 +116,11 @@ void E131Component::leave_(int universe) {
ESP_LOGD(TAG, "Left %d universe for E1.31.", universe);
}
bool E131Component::packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet) {
if (len < E131_MIN_PACKET_SIZE)
bool E131Component::packet_(const std::vector<uint8_t> &data, int &universe, E131Packet &packet) {
if (data.size() < E131_MIN_PACKET_SIZE)
return false;
auto *sbuff = reinterpret_cast<const E131RawPacket *>(data);
auto *sbuff = reinterpret_cast<const E131RawPacket *>(&data[0]);
if (memcmp(sbuff->acn_id, ACN_ID, sizeof(sbuff->acn_id)) != 0)
return false;

View File

@@ -1,67 +0,0 @@
#pragma once
#include <cstdint>
#include <algorithm>
#include "esphome/core/color.h"
/* Utility for converting internal \a Color RGB representation to supported IC hardware color keys
*
* Focus in driver layer is on efficiency.
* For optimum output quality on RGB inputs consider offline color keying/dithering.
* Also see e.g. Image component.
*/
namespace esphome::epaper_spi {
/** Delta for when to regard as gray */
static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
/** Map RGB color to discrete BWYR hex 4 color key
*
* @tparam NATIVE_COLOR Type of native hardware color values
* @param color RGB color to convert from
* @param hw_black Native value for black
* @param hw_white Native value for white
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow,
NATIVE_COLOR hw_red) {
// --- Step 1: Check for Grayscale (Black or White) ---
// We define "grayscale" as a color where the min and max components
// are close to each other.
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
// It's a shade of gray. Map to BLACK or WHITE.
// We split the luminance at the halfway point (382 = (255*3)/2)
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
}
// --- Step 2: Check for Primary/Secondary Colors ---
// If it's not gray, it's a color. We check which components are
// "on" (over 128) vs "off". This divides the RGB cube into 8 corners.
const bool r_on = (color.r > 128);
const bool g_on = (color.g > 128);
const bool b_on = (color.b > 128);
if (r_on) {
if (!b_on) {
return g_on ? hw_yellow : hw_red;
}
// At least red+blue high (but not gray) -> White
return hw_white;
} else {
return (b_on && g_on) ? hw_white : hw_black;
}
}
} // namespace esphome::epaper_spi

View File

@@ -1,227 +0,0 @@
#include "epaper_spi_jd79660.h"
#include "colorconv.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.jd79660";
/** Pixel color as 2bpp. Must match IC LUT values. */
enum JD79660Color : uint8_t {
BLACK = 0b00,
WHITE = 0b01,
YELLOW = 0b10,
RED = 0b11,
};
/** Map RGB color to JD79660 BWYR hex color keys */
static JD79660Color HOT color_to_hex(Color color) {
return color_to_bwyr(color, JD79660Color::BLACK, JD79660Color::WHITE, JD79660Color::YELLOW, JD79660Color::RED);
}
void EPaperJD79660::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
const auto pixel_color = color_to_hex(color);
// We store 4 pixels per byte
this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6));
}
void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
const auto pixel_bits = color_to_hex(color);
const uint32_t pixel_position = x + y * this->get_width_internal();
// We store 4 pixels per byte at LSB offsets 6, 4, 2, 0
const uint32_t byte_position = pixel_position / 4;
const uint32_t bit_offset = 6 - ((pixel_position % 4) * 2);
const auto original = this->buffer_[byte_position];
this->buffer_[byte_position] = (original & (~(0b11 << bit_offset))) | // mask old 2bpp
(pixel_bits << bit_offset); // add new 2bpp
}
bool EPaperJD79660::reset() {
// On entry state RESET set step, next state will be RESET_END
if (this->state_ == EPaperState::RESET) {
this->step_ = FSMState::RESET_STEP0_H;
}
switch (this->step_) {
case FSMState::RESET_STEP0_H:
// Step #0: Reset H for some settle time.
ESP_LOGVV(TAG, "reset #0");
this->reset_pin_->digital_write(true);
this->reset_duration_ = SLEEP_MS_RESET0;
this->step_ = FSMState::RESET_STEP1_L;
return false; // another loop: step #1 below
case FSMState::RESET_STEP1_L:
// Step #1: Reset L pulse for slightly >1.5ms.
// This is actual reset trigger.
ESP_LOGVV(TAG, "reset #1");
// As commented on SLEEP_MS_RESET1: Reset pulse must happen within time window.
// So do not use FSM loop, and avoid other calls/logs during pulse below.
this->reset_pin_->digital_write(false);
delay(SLEEP_MS_RESET1);
this->reset_pin_->digital_write(true);
this->reset_duration_ = SLEEP_MS_RESET2;
this->step_ = FSMState::RESET_STEP2_IDLECHECK;
return false; // another loop: step #2 below
case FSMState::RESET_STEP2_IDLECHECK:
// Step #2: Basically finished. Check sanity, and move FSM to INITIALISE state
ESP_LOGVV(TAG, "reset #2");
if (!this->is_idle_()) {
// Expectation: Idle after reset + settle time.
// Improperly connected/unexpected hardware?
// Error path reproducable e.g. with disconnected VDD/... pins
// (optimally while busy_pin configured with local pulldown).
// -> Mark failed to avoid followup problems.
this->mark_failed(LOG_STR("Busy after reset"));
}
break; // End state loop below
default:
// Unexpected step = bug?
this->mark_failed();
}
this->step_ = FSMState::INIT_STEP0_REGULARINIT; // reset for initialize state
return true;
}
bool EPaperJD79660::initialise(bool partial) {
switch (this->step_) {
case FSMState::INIT_STEP0_REGULARINIT:
// Step #0: Regular init sequence
ESP_LOGVV(TAG, "init #0");
if (!EPaperBase::initialise(partial)) { // Call parent impl
return false; // If parent should request another loop, do so
}
// Fast init requested + supported?
if (partial && (this->fast_update_length_ > 0)) {
this->step_ = FSMState::INIT_STEP1_FASTINIT;
this->wait_for_idle_(true); // Must wait for idle before fastinit sequence in next loop
return false; // another loop: step #1 below
}
break; // End state loop below
case FSMState::INIT_STEP1_FASTINIT:
// Step #1: Fast init sequence
ESP_LOGVV(TAG, "init #1");
this->write_fastinit_();
break; // End state loop below
default:
// Unexpected step = bug?
this->mark_failed();
}
this->step_ = FSMState::NONE;
return true; // Finished: State transition waits for idle
}
bool EPaperJD79660::transfer_buffer_chunks_() {
size_t buf_idx = 0;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
const uint32_t start_time = App.get_loop_component_start_time();
const auto buffer_length = this->buffer_length_;
while (this->current_data_index_ != buffer_length) {
bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++];
if (buf_idx == sizeof bytes_to_send) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGVV(TAG, "Wrote %zu bytes at %ums", buf_idx, (unsigned) millis());
buf_idx = 0;
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
return false;
}
}
}
// Finished the entire dataset
if (buf_idx != 0) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGVV(TAG, "Wrote %zu bytes at %ums", buf_idx, (unsigned) millis());
}
// Cleanup for next transfer
this->current_data_index_ = 0;
// Finished with all buffer chunks
return true;
}
void EPaperJD79660::write_fastinit_() {
// Undocumented register sequence in vendor register range.
// Related to Fast Init/Update.
// Should likely happen after regular init seq and power on, but before refresh.
// Might only work for some models with certain factory MTP.
// Please do not change without knowledge to avoid breakage.
this->send_init_sequence_(this->fast_update_, this->fast_update_length_);
}
bool EPaperJD79660::transfer_data() {
// For now always send full frame buffer in chunks.
// JD79660 might support partial window transfers. But sample code missing.
// And likely minimal impact, solely on SPI transfer time into RAM.
if (this->current_data_index_ == 0) {
this->command(CMD_TRANSFER);
}
return this->transfer_buffer_chunks_();
}
void EPaperJD79660::refresh_screen([[maybe_unused]] bool partial) {
ESP_LOGV(TAG, "Refresh");
this->cmd_data(CMD_REFRESH, {(uint8_t) 0x00});
}
void EPaperJD79660::power_off() {
ESP_LOGV(TAG, "Power off");
this->cmd_data(CMD_POWEROFF, {(uint8_t) 0x00});
}
void EPaperJD79660::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
// "Deepsleep between update": Ensure EPD sleep to avoid early hardware wearout!
this->cmd_data(CMD_DEEPSLEEP, {(uint8_t) 0xA5});
// Notes:
// - VDD: Some boards (Waveshare) with "clever reset logic" would allow switching off
// EPD VDD by pulling reset pin low for longer time.
// However, a) not all boards have this, b) reliable sequence timing is difficult,
// c) saving is not worth it after deepsleep command above.
// If needed: Better option is to drive VDD via MOSFET with separate enable pin.
//
// - Possible safe shutdown:
// EPaperBase::on_safe_shutdown() may also trigger deep_sleep() again.
// Regularly, in IDLE state, this does not make sense for this "deepsleep between update" model,
// but SPI sequence should simply be ignored by sleeping receiver.
// But if triggering during lengthy update, this quick SPI sleep sequence may have benefit.
// Optimally, EPDs should even be set all white for longer storage.
// But full sequence (>15s) not possible w/o app logic.
}
} // namespace esphome::epaper_spi

View File

@@ -1,145 +0,0 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
/**
* JD7966x IC driver implementation
*
* Currently tested with:
* - JD79660 (max res: 200x200)
*
* May also work for other JD7966x chipset family members with minimal adaptations.
*
* Capabilities:
* - HW frame buffer layout:
* 4 colors (gray0..3, commonly BWYR). Bytes consist of 4px/2bpp.
* Width must be rounded to multiple of 4.
* - Fast init/update (shorter wave forms): Yes. Controlled by CONF_FULL_UPDATE_EVERY.
* Needs undocumented fastinit sequence, based on likely vendor specific MTP content.
* - Partial transfer (transfer only changed window): No. Maybe possible by HW.
* - Partial refresh (refresh only changed window): No. Likely HW limit.
*
* @internal \c final saves few bytes by devirtualization. Remove \c final when subclassing.
*/
class EPaperJD79660 final : public EPaperBase {
public:
EPaperJD79660(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, const uint8_t *fast_update, uint16_t fast_update_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR),
fast_update_(fast_update),
fast_update_length_(fast_update_length) {
this->row_width_ = (width + 3) / 4; // Fix base class calc (2bpp instead of 1bpp)
this->buffer_length_ = this->row_width_ * height;
}
void fill(Color color) override;
protected:
/** Draw colored pixel into frame buffer */
void draw_pixel_at(int x, int y, Color color) override;
/** Reset (multistep sequence)
* @pre this->reset_pin_ != nullptr // cv.Required check
* @post Should be idle on successful reset. Can mark failures.
*/
bool reset() override;
/** Initialise (multistep sequence) */
bool initialise(bool partial) override;
/** Buffer transfer */
bool transfer_data() override;
/** Power on: Already part of init sequence (likely needed there before transferring buffers).
* So nothing to do in FSM state.
*/
void power_on() override {}
/** Refresh screen
* @param partial Ignored: Needed earlier in \a ::initialize
* @pre Must be idle.
* @post Should return to idle later after processing.
*/
void refresh_screen([[maybe_unused]] bool partial) override;
/** Power off
* @pre Must be idle.
* @post Should return to idle later after processing.
* (latter will take long period like ~15-20s on actual refresh!)
*/
void power_off() override;
/** Deepsleep: Must be used to avoid hardware wearout!
* @pre Must be idle.
* @post Will go busy, and not return idle till ::reset!
*/
void deep_sleep() override;
/** Internal: Send fast init sequence via undocumented vendor registers
* @pre Must be directly after regular ::initialise sequence, before ::transfer_data
* @pre Must be idle.
* @post Should return to idle later after processing.
*/
void write_fastinit_();
/** Internal: Send raw buffer in chunks
* \retval true Finished
* \retval false Loop time elapsed. Need to call again next loop.
*/
bool transfer_buffer_chunks_();
/** @name IC commands @{ */
static constexpr uint8_t CMD_POWEROFF = 0x02;
static constexpr uint8_t CMD_DEEPSLEEP = 0x07;
static constexpr uint8_t CMD_TRANSFER = 0x10;
static constexpr uint8_t CMD_REFRESH = 0x12;
/** @} */
/** State machine constants for \a step_ */
enum class FSMState : uint8_t {
NONE = 0, //!< Initial/default value: Unused
/* Reset state steps */
RESET_STEP0_H,
RESET_STEP1_L,
RESET_STEP2_IDLECHECK,
/* Init state steps */
INIT_STEP0_REGULARINIT,
INIT_STEP1_FASTINIT,
};
/** Wait time (millisec) for first reset phase: High
*
* Wait via FSM loop.
*/
static constexpr uint16_t SLEEP_MS_RESET0 = 200;
/** Wait time (millisec) for second reset phase: Low
*
* Holding Reset Low too long may trigger "clever reset" logic
* of e.g. Waveshare Rev2 boards: VDD is shut down via MOSFET, and IC
* will not report idle anymore!
* FSM loop may spuriously increase delay, e.g. >16ms.
* Therefore, sync wait below, as allowed (code rule "delays > 10ms not permitted"),
* yet only slightly exceeding known IC min req of >1.5ms.
*/
static constexpr uint16_t SLEEP_MS_RESET1 = 2;
/** Wait time (millisec) for third reset phase: High
*
* Wait via FSM loop.
*/
static constexpr uint16_t SLEEP_MS_RESET2 = 200;
// properties initialised in the constructor
const uint8_t *const fast_update_{};
const uint16_t fast_update_length_{};
/** Counter for tracking substeps within FSM state */
FSMState step_{FSMState::NONE};
};
} // namespace esphome::epaper_spi

View File

@@ -4,7 +4,7 @@
namespace esphome::epaper_spi {
class EPaperSpectraE6 final : public EPaperBase {
class EPaperSpectraE6 : public EPaperBase {
public:
EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)

View File

@@ -6,7 +6,7 @@ namespace esphome::epaper_spi {
/**
* An epaper display that needs LUTs to be sent to it.
*/
class EpaperWaveshare final : public EPaperMono {
class EpaperWaveshare : public EPaperMono {
public:
EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut,

View File

@@ -1,86 +0,0 @@
import esphome.codegen as cg
from esphome.components.mipi import flatten_sequence
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN
from esphome.core import ID
from ..display import CONF_INIT_SEQUENCE_ID
from . import EpaperModel
class JD79660(EpaperModel):
def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs):
super().__init__(name, class_name, **kwargs)
self.fast_update = fast_update
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
# Validate required pins, as C++ code will assume existence
if name in (CONF_RESET_PIN, CONF_BUSY_PIN):
return cv.Required(name)
# Delegate to parent
return super().option(name, fallback)
def get_constructor_args(self, config) -> tuple:
# Resembles init_sequence handling for fast_update config
if self.fast_update is None:
fast_update = cg.nullptr, 0
else:
flat_fast_update = flatten_sequence(self.fast_update)
fast_update = (
cg.static_const_array(
ID(
config[CONF_INIT_SEQUENCE_ID].id + "_fast_update", type=cg.uint8
),
flat_fast_update,
),
len(flat_fast_update),
)
return (*fast_update,)
jd79660 = JD79660(
"jd79660",
# Specified refresh times are ~20s (full) or ~15s (fast) due to BWRY.
# So disallow low update intervals (with safety margin), to avoid e.g. FSM update loops.
# Even less frequent intervals (min/h) highly recommended to optimize lifetime!
minimum_update_interval="30s",
# SPI rate: From spec comparisons, IC should allow SCL write cycles up to 10MHz rate.
# Existing code samples also prefer 10MHz. So justifies as default.
# Decrease value further in user config if needed (e.g. poor cabling).
data_rate="10MHz",
# No need to set optional reset_duration:
# Code requires multistep reset sequence with precise timings
# according to data sheet or samples.
)
# Waveshare 1.54-G
#
# Device may have specific factory provisioned MTP content to facilitate vendor register features like fast init.
# Vendor specific init derived from vendor sample code
# <https://github.com/waveshareteam/e-Paper/blob/master/E-paper_Separate_Program/1in54_e-Paper_G/ESP32/EPD_1in54g.cpp>
# Compatible MIT license, see esphome/LICENSE file.
#
# fmt: off
jd79660.extend(
"Waveshare-1.54in-G",
width=200,
height=200,
initsequence=(
(0x4D, 0x78,),
(0x00, 0x0F, 0x29,),
(0x06, 0x0d, 0x12, 0x30, 0x20, 0x19, 0x2a, 0x22,),
(0x50, 0x37,),
(0x61, 200 // 256, 200 % 256, 200 // 256, 200 % 256,), # RES: 200x200 fixed
(0xE9, 0x01,),
(0x30, 0x08,),
# Power On (0x04): Must be early part of init seq = Disabled later!
(0x04,),
),
fast_update=(
(0xE0, 0x02,),
(0xE6, 0x5D,),
(0xA5, 0x00,),
),
)

View File

@@ -2,18 +2,21 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace fan {
static const char *const TAG = "fan";
// Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN
PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN");
const LogString *fan_direction_to_string(FanDirection direction) {
return FanDirectionStrings::get_log_str(static_cast<uint8_t>(direction), FanDirectionStrings::LAST_INDEX);
switch (direction) {
case FanDirection::FORWARD:
return LOG_STR("FORWARD");
case FanDirection::REVERSE:
return LOG_STR("REVERSE");
default:
return LOG_STR("UNKNOWN");
}
}
FanCall &FanCall::set_preset_mode(const std::string &preset_mode) {

View File

@@ -1,6 +1,5 @@
#include "gpio_binary_sensor.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace gpio {
@@ -8,12 +7,17 @@ namespace gpio {
static const char *const TAG = "gpio.binary_sensor";
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
// Interrupt type strings indexed by edge-triggered InterruptType values:
// indices 1-3: RISING_EDGE, FALLING_EDGE, ANY_EDGE; other values (e.g. level-triggered) map to UNKNOWN (index 0).
PROGMEM_STRING_TABLE(InterruptTypeStrings, "UNKNOWN", "RISING_EDGE", "FALLING_EDGE", "ANY_EDGE");
static const LogString *interrupt_type_to_string(gpio::InterruptType type) {
return InterruptTypeStrings::get_log_str(static_cast<uint8_t>(type), 0);
switch (type) {
case gpio::INTERRUPT_RISING_EDGE:
return LOG_STR("RISING_EDGE");
case gpio::INTERRUPT_FALLING_EDGE:
return LOG_STR("FALLING_EDGE");
case gpio::INTERRUPT_ANY_EDGE:
return LOG_STR("ANY_EDGE");
default:
return LOG_STR("UNKNOWN");
}
}
static const LogString *gpio_mode_to_string(bool use_interrupt) {

View File

@@ -133,10 +133,20 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
// HTTPClient::getSize() returns -1 for chunked transfer encoding (no Content-Length).
// When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit).
// The read() method uses a chunked transfer encoding decoder (read_chunked_) to strip
// chunk framing and deliver only decoded content. When the final 0-size chunk is received,
// is_chunked_ is cleared and content_length is set to the actual decoded size, so
// is_read_complete() returns true and callers exit their read loops correctly.
// The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the
// early return check (bytes_read_ >= content_length) will never trigger.
//
// TODO: Chunked transfer encoding is NOT properly supported on Arduino.
// The implementation in #7884 was incomplete - it only works correctly on ESP-IDF where
// esp_http_client_read() decodes chunks internally. On Arduino, using getStreamPtr()
// returns raw TCP data with chunk framing (e.g., "12a\r\n{json}\r\n0\r\n\r\n") instead
// of decoded content. This wasn't noticed because requests would complete and payloads
// were only examined on IDF. The long transfer times were also masked by the misleading
// "HTTP on Arduino version >= 3.1 is **very** slow" warning above. This causes two issues:
// 1. Response body is corrupted - contains chunk size headers mixed with data
// 2. Cannot detect end of transfer - connection stays open (keep-alive), causing timeout
// The proper fix would be to use getString() for chunked responses, which decodes chunks
// internally, but this buffers the entire response in memory.
int content_length = container->client_.getSize();
ESP_LOGD(TAG, "Content-Length: %d", content_length);
container->content_length = (size_t) content_length;
@@ -164,10 +174,6 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
// > 0: bytes read
// 0: no data yet, retry <-- NOTE: 0 means retry, NOT EOF!
// < 0: error/connection closed <-- connection closed returns -1, not 0
//
// For chunked transfer encoding, read_chunked_() decodes chunk framing and delivers
// only the payload data. When the final 0-size chunk is received, it clears is_chunked_
// and sets content_length = bytes_read_ so is_read_complete() returns true.
int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
@@ -178,42 +184,24 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
return HTTP_ERROR_CONNECTION_CLOSED;
}
if (this->is_chunked_) {
int result = this->read_chunked_(buf, max_len, stream_ptr);
this->duration_ms += (millis() - start);
if (result > 0) {
return result;
}
// result <= 0: check for completion or errors
if (this->is_read_complete()) {
return 0; // Chunked transfer complete (final 0-size chunk received)
}
if (result < 0) {
return result; // Stream error during chunk decoding
}
// read_chunked_ returned 0: no data was available (available() was 0).
// This happens when the TCP buffer is empty - either more data is in flight,
// or the connection dropped. Arduino's connected() returns false only when
// both the remote has closed AND the receive buffer is empty, so any buffered
// data is fully drained before we report the drop.
if (!stream_ptr->connected()) {
return HTTP_ERROR_CONNECTION_CLOSED;
}
return 0; // No data yet, caller should retry
}
// Non-chunked path
int available_data = stream_ptr->available();
// For chunked transfer encoding, HTTPClient::getSize() returns -1, which becomes SIZE_MAX when
// cast to size_t. SIZE_MAX - bytes_read_ is still huge, so it won't limit the read.
size_t remaining = (this->content_length > 0) ? (this->content_length - this->bytes_read_) : max_len;
int bufsize = std::min(max_len, std::min(remaining, (size_t) available_data));
if (bufsize == 0) {
this->duration_ms += (millis() - start);
// Check if we've read all expected content (non-chunked only)
// For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false
if (this->is_read_complete()) {
return 0; // All content read successfully
}
// No data available - check if connection is still open
// For chunked encoding, !connected() after reading means EOF (all chunks received)
// For known content_length with bytes_read_ < content_length, it means connection dropped
if (!stream_ptr->connected()) {
return HTTP_ERROR_CONNECTION_CLOSED;
return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed or EOF for chunked
}
return 0; // No data yet, caller should retry
}
@@ -227,143 +215,6 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
return read_len;
}
void HttpContainerArduino::chunk_header_complete_() {
if (this->chunk_remaining_ == 0) {
this->chunk_state_ = ChunkedState::CHUNK_TRAILER;
this->chunk_remaining_ = 1; // repurpose as at-start-of-line flag
} else {
this->chunk_state_ = ChunkedState::CHUNK_DATA;
}
}
// Chunked transfer encoding decoder
//
// On Arduino, getStreamPtr() returns raw TCP data. For chunked responses, this includes
// chunk framing (size headers, CRLF delimiters) mixed with payload data. This decoder
// strips the framing and delivers only decoded content to the caller.
//
// Chunk format (RFC 9112 Section 7.1):
// <hex-size>[;extension]\r\n
// <data bytes>\r\n
// ...
// 0\r\n
// [trailer-field\r\n]*
// \r\n
//
// Non-blocking: only processes bytes already in the TCP receive buffer.
// State (chunk_state_, chunk_remaining_) is preserved between calls, so partial
// chunk headers or split \r\n sequences resume correctly on the next call.
// Framing bytes (hex sizes, \r\n) may be consumed without producing output;
// the caller sees 0 and retries via the normal read timeout logic.
//
// WiFiClient::read() returns -1 on error despite available() > 0 (connection reset
// between check and read). On any stream error (c < 0 or readBytes <= 0), we return
// already-decoded data if any; otherwise HTTP_ERROR_CONNECTION_CLOSED. The error
// will surface again on the next call since the stream stays broken.
//
// Returns: > 0 decoded bytes, 0 no data available, < 0 error
int HttpContainerArduino::read_chunked_(uint8_t *buf, size_t max_len, WiFiClient *stream) {
int total_decoded = 0;
while (total_decoded < (int) max_len && this->chunk_state_ != ChunkedState::COMPLETE) {
// Non-blocking: only process what's already buffered
if (stream->available() == 0)
break;
// CHUNK_DATA reads multiple bytes; handle before the single-byte switch
if (this->chunk_state_ == ChunkedState::CHUNK_DATA) {
// Only read what's available, what fits in buf, and what remains in this chunk
size_t to_read =
std::min({max_len - (size_t) total_decoded, this->chunk_remaining_, (size_t) stream->available()});
if (to_read == 0)
break;
App.feed_wdt();
int read_len = stream->readBytes(buf + total_decoded, to_read);
if (read_len <= 0)
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
total_decoded += read_len;
this->chunk_remaining_ -= read_len;
this->bytes_read_ += read_len;
if (this->chunk_remaining_ == 0)
this->chunk_state_ = ChunkedState::CHUNK_DATA_TRAIL;
continue;
}
// All other states consume a single byte
int c = stream->read();
if (c < 0)
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
switch (this->chunk_state_) {
// Parse hex chunk size, one byte at a time: "<hex>[;ext]\r\n"
// Note: if no hex digits are parsed (e.g., bare \r\n), chunk_remaining_ stays 0
// and is treated as the final chunk. This is intentionally lenient — on embedded
// devices, rejecting malformed framing is less useful than terminating cleanly.
// Overflow of chunk_remaining_ from extremely long hex strings (>8 digits on
// 32-bit) is not checked; >4GB chunks are unrealistic on embedded targets and
// would simply cause fewer bytes to be read from that chunk.
case ChunkedState::CHUNK_HEADER:
if (c == '\n') {
// \n terminates the size line; chunk_remaining_ == 0 means last chunk
this->chunk_header_complete_();
} else {
uint8_t hex = parse_hex_char(c);
if (hex != INVALID_HEX_CHAR) {
this->chunk_remaining_ = (this->chunk_remaining_ << 4) | hex;
} else if (c != '\r') {
this->chunk_state_ = ChunkedState::CHUNK_HEADER_EXT; // ';' starts extension, skip to \n
}
}
break;
// Skip chunk extension bytes until \n (e.g., ";name=value\r\n")
case ChunkedState::CHUNK_HEADER_EXT:
if (c == '\n') {
this->chunk_header_complete_();
}
break;
// Consume \r\n trailing each chunk's data
case ChunkedState::CHUNK_DATA_TRAIL:
if (c == '\n') {
this->chunk_state_ = ChunkedState::CHUNK_HEADER;
this->chunk_remaining_ = 0; // reset for next chunk's hex accumulation
}
// else: \r is consumed silently, next iteration gets \n
break;
// Consume optional trailer headers and terminating empty line after final chunk.
// Per RFC 9112 Section 7.1: "0\r\n" is followed by optional "field\r\n" lines
// and a final "\r\n". chunk_remaining_ is repurposed as a flag: 1 = at start
// of line (may be the empty terminator), 0 = mid-line (reading a trailer field).
case ChunkedState::CHUNK_TRAILER:
if (c == '\n') {
if (this->chunk_remaining_ != 0) {
this->chunk_state_ = ChunkedState::COMPLETE; // Empty line terminates trailers
} else {
this->chunk_remaining_ = 1; // End of trailer field, at start of next line
}
} else if (c != '\r') {
this->chunk_remaining_ = 0; // Non-CRLF char: reading a trailer field
}
// \r doesn't change the flag — it's part of \r\n line endings
break;
default:
break;
}
if (this->chunk_state_ == ChunkedState::COMPLETE) {
// Clear chunked flag and set content_length to actual decoded size so
// is_read_complete() returns true and callers exit their read loops
this->is_chunked_ = false;
this->content_length = this->bytes_read_;
}
}
return total_decoded;
}
void HttpContainerArduino::end() {
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
this->client_.end();

View File

@@ -18,17 +18,6 @@
namespace esphome::http_request {
class HttpRequestArduino;
/// State machine for decoding chunked transfer encoding on Arduino
enum class ChunkedState : uint8_t {
CHUNK_HEADER, ///< Reading hex digits of chunk size
CHUNK_HEADER_EXT, ///< Skipping chunk extensions until \n
CHUNK_DATA, ///< Reading chunk data bytes
CHUNK_DATA_TRAIL, ///< Skipping \r\n after chunk data
CHUNK_TRAILER, ///< Consuming trailer headers after final 0-size chunk
COMPLETE, ///< Finished: final chunk and trailers consumed
};
class HttpContainerArduino : public HttpContainer {
public:
int read(uint8_t *buf, size_t max_len) override;
@@ -37,13 +26,6 @@ class HttpContainerArduino : public HttpContainer {
protected:
friend class HttpRequestArduino;
HTTPClient client_{};
/// Decode chunked transfer encoding from the raw stream
int read_chunked_(uint8_t *buf, size_t max_len, WiFiClient *stream);
/// Transition from chunk header to data or trailer based on parsed size
void chunk_header_complete_();
ChunkedState chunk_state_{ChunkedState::CHUNK_HEADER};
size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk
};
class HttpRequestArduino : public HttpRequestComponent {

View File

@@ -133,10 +133,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY)
continue;
// For non-chunked responses, COMPLETE is unreachable (loop condition checks bytes_read < content_length).
// For chunked responses, the decoder sets content_length = bytes_read when the final chunk arrives,
// which causes the loop condition to terminate. But COMPLETE can still be returned if the decoder
// finishes mid-read, so this is needed for correctness.
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
// but this is defensive code in case chunked transfer encoding support is added for OTA in the future.
if (result == HttpReadLoopResult::COMPLETE)
break;
if (result != HttpReadLoopResult::DATA) {

View File

@@ -4,7 +4,6 @@
#include "light_state.h"
#include "esphome/core/log.h"
#include "esphome/core/optional.h"
#include "esphome/core/progmem.h"
namespace esphome::light {
@@ -52,13 +51,26 @@ static void log_invalid_parameter(const char *name, const LogString *message) {
return *this; \
}
// Color mode human-readable strings indexed by ColorModeBitPolicy::to_bit() (0-9)
// Index 0 is Unknown (for ColorMode::UNKNOWN), also used as fallback for out-of-range
PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature",
"Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white");
static const LogString *color_mode_to_human(ColorMode color_mode) {
return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0);
if (color_mode == ColorMode::ON_OFF)
return LOG_STR("On/Off");
if (color_mode == ColorMode::BRIGHTNESS)
return LOG_STR("Brightness");
if (color_mode == ColorMode::WHITE)
return LOG_STR("White");
if (color_mode == ColorMode::COLOR_TEMPERATURE)
return LOG_STR("Color temperature");
if (color_mode == ColorMode::COLD_WARM_WHITE)
return LOG_STR("Cold/warm white");
if (color_mode == ColorMode::RGB)
return LOG_STR("RGB");
if (color_mode == ColorMode::RGB_WHITE)
return LOG_STR("RGBW");
if (color_mode == ColorMode::RGB_COLD_WARM_WHITE)
return LOG_STR("RGB + cold/warm white");
if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE)
return LOG_STR("RGB + color temperature");
return LOG_STR("Unknown");
}
// Helper to log percentage values
@@ -445,52 +457,6 @@ ColorMode LightCall::compute_color_mode_() {
LOG_STR_ARG(color_mode_to_human(color_mode)));
return color_mode;
}
// PROGMEM lookup table for get_suitable_color_modes_mask_().
// Maps 4-bit key (white | ct<<1 | cwww<<2 | rgb<<3) to color mode bitmask.
// Packed into uint8_t by right-shifting by PACK_SHIFT since the lower bits
// (UNKNOWN, ON_OFF, BRIGHTNESS) are never present in suitable mode masks.
static constexpr unsigned PACK_SHIFT = ColorModeBitPolicy::to_bit(ColorMode::WHITE);
// clang-format off
static constexpr uint8_t SUITABLE_COLOR_MODES[] PROGMEM = {
// [0] none - all modes with brightness
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [1] white only
static_cast<uint8_t>(ColorModeMask({ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [2] ct only
static_cast<uint8_t>(ColorModeMask({ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [3] white + ct
static_cast<uint8_t>(ColorModeMask({ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [4] cwww only
static_cast<uint8_t>(ColorModeMask({ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
0, // [5] white + cwww (conflicting)
0, // [6] ct + cwww (conflicting)
0, // [7] white + ct + cwww (conflicting)
// [8] rgb only
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [9] rgb + white
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [10] rgb + ct
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [11] rgb + white + ct
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
// [12] rgb + cwww
static_cast<uint8_t>(ColorModeMask({ColorMode::RGB_COLD_WARM_WHITE}).get_mask() >> PACK_SHIFT),
0, // [13] rgb + white + cwww (conflicting)
0, // [14] rgb + ct + cwww (conflicting)
0, // [15] all (conflicting)
};
// clang-format on
color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
bool has_white = this->has_white() && this->white_ > 0.0f;
bool has_ct = this->has_color_temperature();
@@ -500,8 +466,46 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
(this->has_red() || this->has_green() || this->has_blue());
// Build key from flags: [rgb][cwww][ct][white]
uint8_t key = has_white | (has_ct << 1) | (has_cwww << 2) | (has_rgb << 3);
return static_cast<color_mode_bitmask_t>(progmem_read_byte(&SUITABLE_COLOR_MODES[key])) << PACK_SHIFT;
#define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3)
uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb);
switch (key) {
case KEY(true, false, false, false): // white only
return ColorModeMask({ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, true, false, false): // ct only
return ColorModeMask({ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(true, true, false, false): // white + ct
return ColorModeMask(
{ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, false, true, false): // cwww only
return ColorModeMask({ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, false, false): // none
return ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE,
ColorMode::RGB, ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE})
.get_mask();
case KEY(true, false, false, true): // rgb + white
return ColorModeMask({ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
case KEY(false, true, false, true): // rgb + ct
case KEY(true, true, false, true): // rgb + white + ct
return ColorModeMask({ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, true, true): // rgb + cwww
return ColorModeMask({ColorMode::RGB_COLD_WARM_WHITE}).get_mask();
case KEY(false, false, false, true): // rgb only
return ColorModeMask({ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE})
.get_mask();
default:
return 0; // conflicting flags
}
#undef KEY
}
LightCall &LightCall::set_effect(const char *effect, size_t len) {

View File

@@ -9,19 +9,32 @@ namespace esphome::light {
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
// Color mode JSON strings - packed into flash with compile-time generated offsets.
// Indexed by ColorModeBitPolicy bit index (1-9), so index 0 maps to bit 1 ("onoff").
PROGMEM_STRING_TABLE(ColorModeStrings, "onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct",
"rgbww");
// Get JSON string for color mode. Returns nullptr for UNKNOWN (bit 0).
// Returns ProgmemStr so ArduinoJson knows to handle PROGMEM strings on ESP8266.
// Get JSON string for color mode.
// ColorMode enum values are sparse bitmasks (0, 1, 3, 7, 11, 19, 35, 39, 47, 51) which would
// generate a large jump table. Converting to bit index (0-9) allows a compact switch.
static ProgmemStr get_color_mode_json_str(ColorMode mode) {
unsigned bit = ColorModeBitPolicy::to_bit(mode);
if (bit == 0)
return nullptr;
// bit is 1-9 for valid modes, so bit-1 is always valid (0-8). LAST_INDEX fallback never used.
return ColorModeStrings::get_progmem_str(bit - 1, ColorModeStrings::LAST_INDEX);
switch (ColorModeBitPolicy::to_bit(mode)) {
case 1:
return ESPHOME_F("onoff");
case 2:
return ESPHOME_F("brightness");
case 3:
return ESPHOME_F("white");
case 4:
return ESPHOME_F("color_temp");
case 5:
return ESPHOME_F("cwww");
case 6:
return ESPHOME_F("rgb");
case 7:
return ESPHOME_F("rgbw");
case 8:
return ESPHOME_F("rgbct");
case 9:
return ESPHOME_F("rgbww");
default:
return nullptr;
}
}
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {

View File

@@ -8,12 +8,22 @@ namespace esphome::lock {
static const char *const TAG = "lock";
// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING
// Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range
PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING");
const LogString *lock_state_to_string(LockState state) {
return LockStateStrings::get_log_str(static_cast<uint8_t>(state), 0);
switch (state) {
case LOCK_STATE_LOCKED:
return LOG_STR("LOCKED");
case LOCK_STATE_UNLOCKED:
return LOG_STR("UNLOCKED");
case LOCK_STATE_JAMMED:
return LOG_STR("JAMMED");
case LOCK_STATE_LOCKING:
return LOG_STR("LOCKING");
case LOCK_STATE_UNLOCKING:
return LOG_STR("UNLOCKING");
case LOCK_STATE_NONE:
default:
return LOG_STR("UNKNOWN");
}
}
Lock::Lock() : state(LOCK_STATE_NONE) {}

View File

@@ -4,7 +4,6 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::logger {
@@ -242,20 +241,34 @@ UARTSelection Logger::get_uart() const { return this->uart_; }
float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; }
// Log level strings - packed into flash on ESP8266, indexed by log level (0-7)
PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE");
#ifdef USE_STORE_LOG_STR_IN_FLASH
// ESP8266: PSTR() cannot be used in array initializers, so we need to declare
// each string separately as a global constant first
static const char LOG_LEVEL_NONE[] PROGMEM = "NONE";
static const char LOG_LEVEL_ERROR[] PROGMEM = "ERROR";
static const char LOG_LEVEL_WARN[] PROGMEM = "WARN";
static const char LOG_LEVEL_INFO[] PROGMEM = "INFO";
static const char LOG_LEVEL_CONFIG[] PROGMEM = "CONFIG";
static const char LOG_LEVEL_DEBUG[] PROGMEM = "DEBUG";
static const char LOG_LEVEL_VERBOSE[] PROGMEM = "VERBOSE";
static const char LOG_LEVEL_VERY_VERBOSE[] PROGMEM = "VERY_VERBOSE";
static const LogString *get_log_level_str(uint8_t level) {
return LogLevelStrings::get_log_str(level, LogLevelStrings::LAST_INDEX);
}
static const LogString *const LOG_LEVELS[] = {
reinterpret_cast<const LogString *>(LOG_LEVEL_NONE), reinterpret_cast<const LogString *>(LOG_LEVEL_ERROR),
reinterpret_cast<const LogString *>(LOG_LEVEL_WARN), reinterpret_cast<const LogString *>(LOG_LEVEL_INFO),
reinterpret_cast<const LogString *>(LOG_LEVEL_CONFIG), reinterpret_cast<const LogString *>(LOG_LEVEL_DEBUG),
reinterpret_cast<const LogString *>(LOG_LEVEL_VERBOSE), reinterpret_cast<const LogString *>(LOG_LEVEL_VERY_VERBOSE),
};
#else
static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"};
#endif
void Logger::dump_config() {
ESP_LOGCONFIG(TAG,
"Logger:\n"
" Max Level: %s\n"
" Initial Level: %s",
LOG_STR_ARG(get_log_level_str(ESPHOME_LOG_LEVEL)),
LOG_STR_ARG(get_log_level_str(this->current_level_)));
LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]), LOG_STR_ARG(LOG_LEVELS[this->current_level_]));
#ifndef USE_HOST
ESP_LOGCONFIG(TAG,
" Log Baud Rate: %" PRIu32 "\n"
@@ -274,7 +287,7 @@ void Logger::dump_config() {
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
for (auto &it : this->log_levels_) {
ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first, LOG_STR_ARG(get_log_level_str(it.second)));
ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first, LOG_STR_ARG(LOG_LEVELS[it.second]));
}
#endif
}
@@ -282,8 +295,7 @@ void Logger::dump_config() {
void Logger::set_log_level(uint8_t level) {
if (level > ESPHOME_LOG_LEVEL) {
level = ESPHOME_LOG_LEVEL;
ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s",
LOG_STR_ARG(get_log_level_str(ESPHOME_LOG_LEVEL)));
ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]));
}
this->current_level_ = level;
#ifdef USE_LOGGER_LEVEL_LISTENERS

View File

@@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) {
#ifdef CONFIG_PRINTK
// Requires the debug component and an active SWD connection.
// It is used for pyocd rtt -t nrf52840
printk("%.*s", static_cast<int>(len), msg);
k_str_out(const_cast<char *>(msg), len);
#endif
if (this->uart_dev_ == nullptr) {
return;

View File

@@ -38,29 +38,22 @@ void LPS22Component::dump_config() {
LOG_UPDATE_INTERVAL(this);
}
static constexpr uint32_t INTERVAL_READ = 0;
void LPS22Component::update() {
uint8_t value = 0x00;
this->read_register(CTRL_REG2, &value, 1);
value |= CTRL_REG2_ONE_SHOT_MASK;
this->write_register(CTRL_REG2, &value, 1);
this->read_attempts_remaining_ = READ_ATTEMPTS;
this->set_interval(INTERVAL_READ, READ_INTERVAL, [this]() { this->try_read_(); });
this->set_retry(READ_INTERVAL, READ_ATTEMPTS, [this](uint8_t _) { return this->try_read_(); });
}
void LPS22Component::try_read_() {
RetryResult LPS22Component::try_read_() {
uint8_t value = 0x00;
this->read_register(STATUS, &value, 1);
const uint8_t expected_status_mask = STATUS_T_DA_MASK | STATUS_P_DA_MASK;
if ((value & expected_status_mask) != expected_status_mask) {
ESP_LOGD(TAG, "STATUS not ready: %x", value);
if (--this->read_attempts_remaining_ == 0) {
this->cancel_interval(INTERVAL_READ);
}
return;
return RetryResult::RETRY;
}
this->cancel_interval(INTERVAL_READ);
if (this->temperature_sensor_ != nullptr) {
uint8_t t_buf[2]{0};
@@ -75,6 +68,7 @@ void LPS22Component::try_read_() {
uint32_t p_lsb = encode_uint24(p_buf[2], p_buf[1], p_buf[0]);
this->pressure_sensor_->publish_state(PRESSURE_SCALE * static_cast<float>(p_lsb));
}
return RetryResult::DONE;
}
} // namespace lps22

View File

@@ -17,11 +17,10 @@ class LPS22Component : public sensor::Sensor, public PollingComponent, public i2
void dump_config() override;
protected:
void try_read_();
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *pressure_sensor_{nullptr};
uint8_t read_attempts_remaining_{0};
RetryResult try_read_();
};
} // namespace lps22

View File

@@ -436,7 +436,6 @@ def container_schema(widget_type: WidgetType, extras=None):
schema = schema.extend(widget_type.schema)
def validator(value):
value = value or {}
return append_layout_schema(schema, value)(value)
return validator

View File

@@ -13,12 +13,31 @@ static const char *const TAG = "mqtt.alarm_control_panel";
using namespace esphome::alarm_control_panel;
// Alarm state MQTT strings indexed by AlarmControlPanelState enum (0-9)
PROGMEM_STRING_TABLE(AlarmMqttStateStrings, "disarmed", "armed_home", "armed_away", "armed_night", "armed_vacation",
"armed_custom_bypass", "pending", "arming", "disarming", "triggered", "unknown");
static ProgmemStr alarm_state_to_mqtt_str(AlarmControlPanelState state) {
return AlarmMqttStateStrings::get_progmem_str(static_cast<uint8_t>(state), AlarmMqttStateStrings::LAST_INDEX);
switch (state) {
case ACP_STATE_DISARMED:
return ESPHOME_F("disarmed");
case ACP_STATE_ARMED_HOME:
return ESPHOME_F("armed_home");
case ACP_STATE_ARMED_AWAY:
return ESPHOME_F("armed_away");
case ACP_STATE_ARMED_NIGHT:
return ESPHOME_F("armed_night");
case ACP_STATE_ARMED_VACATION:
return ESPHOME_F("armed_vacation");
case ACP_STATE_ARMED_CUSTOM_BYPASS:
return ESPHOME_F("armed_custom_bypass");
case ACP_STATE_PENDING:
return ESPHOME_F("pending");
case ACP_STATE_ARMING:
return ESPHOME_F("arming");
case ACP_STATE_DISARMING:
return ESPHOME_F("disarming");
case ACP_STATE_TRIGGERED:
return ESPHOME_F("triggered");
default:
return ESPHOME_F("unknown");
}
}
MQTTAlarmControlPanelComponent::MQTTAlarmControlPanelComponent(AlarmControlPanel *alarm_control_panel)

View File

@@ -8,7 +8,6 @@
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/version.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
@@ -28,11 +27,6 @@ namespace esphome::mqtt {
static const char *const TAG = "mqtt";
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
"Identifier Rejected", "Server Unavailable", "Malformed Credentials", "Not Authorized",
"Not Enough Space", "TLS Bad Fingerprint", "DNS Resolve Error", "Unknown");
MQTTClientComponent::MQTTClientComponent() {
global_mqtt_client = this;
char mac_addr[MAC_ADDRESS_BUFFER_SIZE];
@@ -354,8 +348,36 @@ void MQTTClientComponent::loop() {
mqtt_backend_.loop();
if (this->disconnect_reason_.has_value()) {
const LogString *reason_s = MQTTDisconnectReasonStrings::get_log_str(
static_cast<uint8_t>(*this->disconnect_reason_), MQTTDisconnectReasonStrings::LAST_INDEX);
const LogString *reason_s;
switch (*this->disconnect_reason_) {
case MQTTClientDisconnectReason::TCP_DISCONNECTED:
reason_s = LOG_STR("TCP disconnected");
break;
case MQTTClientDisconnectReason::MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
reason_s = LOG_STR("Unacceptable Protocol Version");
break;
case MQTTClientDisconnectReason::MQTT_IDENTIFIER_REJECTED:
reason_s = LOG_STR("Identifier Rejected");
break;
case MQTTClientDisconnectReason::MQTT_SERVER_UNAVAILABLE:
reason_s = LOG_STR("Server Unavailable");
break;
case MQTTClientDisconnectReason::MQTT_MALFORMED_CREDENTIALS:
reason_s = LOG_STR("Malformed Credentials");
break;
case MQTTClientDisconnectReason::MQTT_NOT_AUTHORIZED:
reason_s = LOG_STR("Not Authorized");
break;
case MQTTClientDisconnectReason::ESP8266_NOT_ENOUGH_SPACE:
reason_s = LOG_STR("Not Enough Space");
break;
case MQTTClientDisconnectReason::TLS_BAD_FINGERPRINT:
reason_s = LOG_STR("TLS Bad Fingerprint");
break;
default:
reason_s = LOG_STR("Unknown");
break;
}
if (!network::is_connected()) {
reason_s = LOG_STR("WiFi disconnected");
}

View File

@@ -13,44 +13,109 @@ static const char *const TAG = "mqtt.climate";
using namespace esphome::climate;
// Climate mode MQTT strings indexed by ClimateMode enum (0-6): OFF, HEAT_COOL, COOL, HEAT, FAN_ONLY, DRY, AUTO
PROGMEM_STRING_TABLE(ClimateMqttModeStrings, "off", "heat_cool", "cool", "heat", "fan_only", "dry", "auto", "unknown");
static ProgmemStr climate_mode_to_mqtt_str(ClimateMode mode) {
return ClimateMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), ClimateMqttModeStrings::LAST_INDEX);
switch (mode) {
case CLIMATE_MODE_OFF:
return ESPHOME_F("off");
case CLIMATE_MODE_HEAT_COOL:
return ESPHOME_F("heat_cool");
case CLIMATE_MODE_AUTO:
return ESPHOME_F("auto");
case CLIMATE_MODE_COOL:
return ESPHOME_F("cool");
case CLIMATE_MODE_HEAT:
return ESPHOME_F("heat");
case CLIMATE_MODE_FAN_ONLY:
return ESPHOME_F("fan_only");
case CLIMATE_MODE_DRY:
return ESPHOME_F("dry");
default:
return ESPHOME_F("unknown");
}
}
// Climate action MQTT strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
PROGMEM_STRING_TABLE(ClimateMqttActionStrings, "off", "unknown", "cooling", "heating", "idle", "drying", "fan",
"unknown");
static ProgmemStr climate_action_to_mqtt_str(ClimateAction action) {
return ClimateMqttActionStrings::get_progmem_str(static_cast<uint8_t>(action), ClimateMqttActionStrings::LAST_INDEX);
switch (action) {
case CLIMATE_ACTION_OFF:
return ESPHOME_F("off");
case CLIMATE_ACTION_COOLING:
return ESPHOME_F("cooling");
case CLIMATE_ACTION_HEATING:
return ESPHOME_F("heating");
case CLIMATE_ACTION_IDLE:
return ESPHOME_F("idle");
case CLIMATE_ACTION_DRYING:
return ESPHOME_F("drying");
case CLIMATE_ACTION_FAN:
return ESPHOME_F("fan");
default:
return ESPHOME_F("unknown");
}
}
// Climate fan mode MQTT strings indexed by ClimateFanMode enum (0-9)
PROGMEM_STRING_TABLE(ClimateMqttFanModeStrings, "on", "off", "auto", "low", "medium", "high", "middle", "focus",
"diffuse", "quiet", "unknown");
static ProgmemStr climate_fan_mode_to_mqtt_str(ClimateFanMode fan_mode) {
return ClimateMqttFanModeStrings::get_progmem_str(static_cast<uint8_t>(fan_mode),
ClimateMqttFanModeStrings::LAST_INDEX);
switch (fan_mode) {
case CLIMATE_FAN_ON:
return ESPHOME_F("on");
case CLIMATE_FAN_OFF:
return ESPHOME_F("off");
case CLIMATE_FAN_AUTO:
return ESPHOME_F("auto");
case CLIMATE_FAN_LOW:
return ESPHOME_F("low");
case CLIMATE_FAN_MEDIUM:
return ESPHOME_F("medium");
case CLIMATE_FAN_HIGH:
return ESPHOME_F("high");
case CLIMATE_FAN_MIDDLE:
return ESPHOME_F("middle");
case CLIMATE_FAN_FOCUS:
return ESPHOME_F("focus");
case CLIMATE_FAN_DIFFUSE:
return ESPHOME_F("diffuse");
case CLIMATE_FAN_QUIET:
return ESPHOME_F("quiet");
default:
return ESPHOME_F("unknown");
}
}
// Climate swing mode MQTT strings indexed by ClimateSwingMode enum (0-3): OFF, BOTH, VERTICAL, HORIZONTAL
PROGMEM_STRING_TABLE(ClimateMqttSwingModeStrings, "off", "both", "vertical", "horizontal", "unknown");
static ProgmemStr climate_swing_mode_to_mqtt_str(ClimateSwingMode swing_mode) {
return ClimateMqttSwingModeStrings::get_progmem_str(static_cast<uint8_t>(swing_mode),
ClimateMqttSwingModeStrings::LAST_INDEX);
switch (swing_mode) {
case CLIMATE_SWING_OFF:
return ESPHOME_F("off");
case CLIMATE_SWING_BOTH:
return ESPHOME_F("both");
case CLIMATE_SWING_VERTICAL:
return ESPHOME_F("vertical");
case CLIMATE_SWING_HORIZONTAL:
return ESPHOME_F("horizontal");
default:
return ESPHOME_F("unknown");
}
}
// Climate preset MQTT strings indexed by ClimatePreset enum (0-7)
PROGMEM_STRING_TABLE(ClimateMqttPresetStrings, "none", "home", "away", "boost", "comfort", "eco", "sleep", "activity",
"unknown");
static ProgmemStr climate_preset_to_mqtt_str(ClimatePreset preset) {
return ClimateMqttPresetStrings::get_progmem_str(static_cast<uint8_t>(preset), ClimateMqttPresetStrings::LAST_INDEX);
switch (preset) {
case CLIMATE_PRESET_NONE:
return ESPHOME_F("none");
case CLIMATE_PRESET_HOME:
return ESPHOME_F("home");
case CLIMATE_PRESET_ECO:
return ESPHOME_F("eco");
case CLIMATE_PRESET_AWAY:
return ESPHOME_F("away");
case CLIMATE_PRESET_BOOST:
return ESPHOME_F("boost");
case CLIMATE_PRESET_COMFORT:
return ESPHOME_F("comfort");
case CLIMATE_PRESET_SLEEP:
return ESPHOME_F("sleep");
case CLIMATE_PRESET_ACTIVITY:
return ESPHOME_F("activity");
default:
return ESPHOME_F("unknown");
}
}
void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {

View File

@@ -14,9 +14,6 @@ namespace esphome::mqtt {
static const char *const TAG = "mqtt.component";
// Entity category MQTT strings indexed by EntityCategory enum: NONE(0) is skipped, CONFIG(1), DIAGNOSTIC(2)
PROGMEM_STRING_TABLE(EntityCategoryMqttStrings, "", "config", "diagnostic");
// Helper functions for building topic strings on stack
inline char *append_str(char *p, const char *s, size_t len) {
memcpy(p, s, len);
@@ -34,7 +31,10 @@ inline char *append_char(char *p, char c) {
// MQTT_COMPONENT_TYPE_MAX_LEN, MQTT_SUFFIX_MAX_LEN, and MQTT_DEFAULT_TOPIC_MAX_LEN are in mqtt_component.h.
// ESPHOME_DEVICE_NAME_MAX_LEN and OBJECT_ID_MAX_LEN are defined in entity_base.h.
// This ensures the stack buffers below are always large enough.
// MQTT_DISCOVERY_PREFIX_MAX_LEN and MQTT_DISCOVERY_TOPIC_MAX_LEN are defined in mqtt_component.h
static constexpr size_t DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null
static constexpr size_t DISCOVERY_TOPIC_MAX_LEN = DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 +
ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1;
// Function implementation of LOG_MQTT_COMPONENT macro to reduce code size
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) {
@@ -51,15 +51,15 @@ void MQTTComponent::set_subscribe_qos(uint8_t qos) { this->subscribe_qos_ = qos;
void MQTTComponent::set_retain(bool retain) { this->retain_ = retain; }
StringRef MQTTComponent::get_discovery_topic_to_(std::span<char, MQTT_DISCOVERY_TOPIC_MAX_LEN> buf,
const MQTTDiscoveryInfo &discovery_info) const {
std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const {
char sanitized_name[ESPHOME_DEVICE_NAME_MAX_LEN + 1];
str_sanitize_to(sanitized_name, App.get_name().c_str());
const char *comp_type = this->component_type();
char object_id_buf[OBJECT_ID_MAX_LEN];
StringRef object_id = this->get_default_object_id_to_(object_id_buf);
char *p = buf.data();
char buf[DISCOVERY_TOPIC_MAX_LEN];
char *p = buf;
p = append_str(p, discovery_info.prefix.data(), discovery_info.prefix.size());
p = append_char(p, '/');
@@ -69,9 +69,8 @@ StringRef MQTTComponent::get_discovery_topic_to_(std::span<char, MQTT_DISCOVERY_
p = append_char(p, '/');
p = append_str(p, object_id.c_str(), object_id.size());
p = append_str(p, "/config", 7);
*p = '\0';
return StringRef(buf.data(), p - buf.data());
return std::string(buf, p - buf);
}
StringRef MQTTComponent::get_default_topic_for_to_(std::span<char, MQTT_DEFAULT_TOPIC_MAX_LEN> buf, const char *suffix,
@@ -180,19 +179,16 @@ bool MQTTComponent::publish_json(const char *topic, const json::json_build_t &f)
bool MQTTComponent::send_discovery_() {
const MQTTDiscoveryInfo &discovery_info = global_mqtt_client->get_discovery_info();
char discovery_topic_buf[MQTT_DISCOVERY_TOPIC_MAX_LEN];
StringRef discovery_topic = this->get_discovery_topic_to_(discovery_topic_buf, discovery_info);
if (discovery_info.clean) {
ESP_LOGV(TAG, "'%s': Cleaning discovery", this->friendly_name_().c_str());
return global_mqtt_client->publish(discovery_topic.c_str(), "", 0, this->qos_, true);
return global_mqtt_client->publish(this->get_discovery_topic_(discovery_info), "", 0, this->qos_, true);
}
ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name_().c_str());
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return global_mqtt_client->publish_json(
discovery_topic.c_str(),
this->get_discovery_topic_(discovery_info),
[this](JsonObject root) {
SendDiscoveryConfig config;
config.state_topic = true;
@@ -205,7 +201,7 @@ bool MQTTComponent::send_discovery_() {
}
// Fields from EntityBase
root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : StringRef();
root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : "";
if (this->is_disabled_by_default_())
root[MQTT_ENABLED_BY_DEFAULT] = false;
@@ -217,9 +213,13 @@ bool MQTTComponent::send_discovery_() {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
const auto entity_category = this->get_entity()->get_entity_category();
if (entity_category != ENTITY_CATEGORY_NONE) {
root[MQTT_ENTITY_CATEGORY] = EntityCategoryMqttStrings::get_progmem_str(
static_cast<uint8_t>(entity_category), static_cast<uint8_t>(ENTITY_CATEGORY_CONFIG));
switch (entity_category) {
case ENTITY_CATEGORY_NONE:
break;
case ENTITY_CATEGORY_CONFIG:
case ENTITY_CATEGORY_DIAGNOSTIC:
root[MQTT_ENTITY_CATEGORY] = entity_category == ENTITY_CATEGORY_CONFIG ? "config" : "diagnostic";
break;
}
if (config.state_topic) {
@@ -249,7 +249,7 @@ bool MQTTComponent::send_discovery_() {
if (discovery_info.unique_id_generator == MQTT_MAC_ADDRESS_UNIQUE_ID_GENERATOR) {
char friendly_name_hash[9];
buf_append_printf(friendly_name_hash, sizeof(friendly_name_hash), 0, "%08" PRIx32,
fnv1_hash(this->friendly_name_().c_str()));
fnv1_hash(this->friendly_name_()));
// Format: mac-component_type-hash (e.g. "aabbccddeeff-sensor-12345678")
// MAC (12) + "-" (1) + domain (max 20) + "-" (1) + hash (8) + null (1) = 43
char unique_id[MAC_ADDRESS_BUFFER_SIZE + ESPHOME_DOMAIN_MAX_LEN + 11];
@@ -415,7 +415,7 @@ void MQTTComponent::schedule_resend_state() { this->resend_state_ = true; }
bool MQTTComponent::is_connected_() const { return global_mqtt_client->is_connected(); }
// Pull these properties from EntityBase if not overridden
const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); }
std::string MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); }
StringRef MQTTComponent::get_default_object_id_to_(std::span<char, OBJECT_ID_MAX_LEN> buf) const {
return this->get_entity()->get_object_id_to(buf);
}

View File

@@ -32,10 +32,6 @@ static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python:
// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null
static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN =
MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1;
static constexpr size_t MQTT_DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64)
// Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null
static constexpr size_t MQTT_DISCOVERY_TOPIC_MAX_LEN = MQTT_DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN +
1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1;
class MQTTComponent; // Forward declaration
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic);
@@ -267,9 +263,8 @@ class MQTTComponent : public Component {
void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0);
protected:
/// Helper method to get the discovery topic for this component into a buffer.
StringRef get_discovery_topic_to_(std::span<char, MQTT_DISCOVERY_TOPIC_MAX_LEN> buf,
const MQTTDiscoveryInfo &discovery_info) const;
/// Helper method to get the discovery topic for this component.
std::string get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const;
/** Get this components state/command/... topic into a buffer.
*
@@ -293,7 +288,7 @@ class MQTTComponent : public Component {
virtual const EntityBase *get_entity() const = 0;
/// Get the friendly name of this MQTT component.
const StringRef &friendly_name_() const;
std::string friendly_name_() const;
/// Get the icon field of this component as StringRef
StringRef get_icon_ref_() const;

View File

@@ -1,6 +1,5 @@
#include "mqtt_number.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "mqtt_const.h"
@@ -13,9 +12,6 @@ static const char *const TAG = "mqtt.number";
using namespace esphome::number;
// Number mode MQTT strings indexed by NumberMode enum: AUTO(0) is skipped, BOX(1), SLIDER(2)
PROGMEM_STRING_TABLE(NumberMqttModeStrings, "", "box", "slider");
MQTTNumberComponent::MQTTNumberComponent(Number *number) : number_(number) {}
void MQTTNumberComponent::setup() {
@@ -52,10 +48,15 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon
if (!unit_of_measurement.empty()) {
root[MQTT_UNIT_OF_MEASUREMENT] = unit_of_measurement;
}
const auto mode = this->number_->traits.get_mode();
if (mode != NUMBER_MODE_AUTO) {
root[MQTT_MODE] =
NumberMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), static_cast<uint8_t>(NUMBER_MODE_BOX));
switch (this->number_->traits.get_mode()) {
case NUMBER_MODE_AUTO:
break;
case NUMBER_MODE_BOX:
root[MQTT_MODE] = "box";
break;
case NUMBER_MODE_SLIDER:
root[MQTT_MODE] = "slider";
break;
}
const auto device_class = this->number_->traits.get_device_class_ref();
if (!device_class.empty()) {

View File

@@ -1,6 +1,5 @@
#include "mqtt_text.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "mqtt_const.h"
@@ -13,9 +12,6 @@ static const char *const TAG = "mqtt.text";
using namespace esphome::text;
// Text mode MQTT strings indexed by TextMode enum (0-1): TEXT, PASSWORD
PROGMEM_STRING_TABLE(TextMqttModeStrings, "text", "password");
MQTTTextComponent::MQTTTextComponent(Text *text) : text_(text) {}
void MQTTTextComponent::setup() {
@@ -38,8 +34,14 @@ const EntityBase *MQTTTextComponent::get_entity() const { return this->text_; }
void MQTTTextComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root[MQTT_MODE] = TextMqttModeStrings::get_progmem_str(static_cast<uint8_t>(this->text_->traits.get_mode()),
static_cast<uint8_t>(TEXT_MODE_TEXT));
switch (this->text_->traits.get_mode()) {
case TEXT_MODE_TEXT:
root[MQTT_MODE] = "text";
break;
case TEXT_MODE_PASSWORD:
root[MQTT_MODE] = "password";
break;
}
config.command_topic = true;
}

View File

@@ -396,9 +396,9 @@ static bool process_rolling_code(Provider &provider, PacketDecoder &decoder) {
/**
* Process a received packet
*/
void PacketTransport::process_(std::span<const uint8_t> data) {
void PacketTransport::process_(const std::vector<uint8_t> &data) {
auto ping_key_seen = !this->ping_pong_enable_;
PacketDecoder decoder(data.data(), data.size());
PacketDecoder decoder((data.data()), data.size());
char namebuf[256]{};
uint8_t byte;
FuData rdata{};

View File

@@ -9,9 +9,8 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#include <map>
#include <span>
#include <vector>
#include <map>
/**
* Providing packet encoding functions for exchanging data with a remote host.
@@ -114,7 +113,7 @@ class PacketTransport : public PollingComponent {
virtual bool should_send() { return true; }
// to be called by child classes when a data packet is received.
void process_(std::span<const uint8_t> data);
void process_(const std::vector<uint8_t> &data);
void send_data_(bool all);
void flush_();
void add_data_(uint8_t key, const char *id, float data);

View File

@@ -2,7 +2,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::sensor {
@@ -31,13 +30,20 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o
}
}
// State class strings indexed by StateClass enum (0-4): NONE, MEASUREMENT, TOTAL_INCREASING, TOTAL, MEASUREMENT_ANGLE
PROGMEM_STRING_TABLE(StateClassStrings, "", "measurement", "total_increasing", "total", "measurement_angle");
static_assert(StateClassStrings::COUNT == STATE_CLASS_LAST + 1, "StateClassStrings must match StateClass enum");
const LogString *state_class_to_string(StateClass state_class) {
// Fallback to index 0 (empty string for STATE_CLASS_NONE) if out of range
return StateClassStrings::get_log_str(static_cast<uint8_t>(state_class), 0);
switch (state_class) {
case STATE_CLASS_MEASUREMENT:
return LOG_STR("measurement");
case STATE_CLASS_TOTAL_INCREASING:
return LOG_STR("total_increasing");
case STATE_CLASS_TOTAL:
return LOG_STR("total");
case STATE_CLASS_MEASUREMENT_ANGLE:
return LOG_STR("measurement_angle");
case STATE_CLASS_NONE:
default:
return LOG_STR("");
}
}
Sensor::Sensor() : state(NAN), raw_state(NAN) {}

View File

@@ -32,7 +32,6 @@ enum StateClass : uint8_t {
STATE_CLASS_TOTAL = 3,
STATE_CLASS_MEASUREMENT_ANGLE = 4
};
constexpr uint8_t STATE_CLASS_LAST = static_cast<uint8_t>(STATE_CLASS_MEASUREMENT_ANGLE);
const LogString *state_class_to_string(StateClass state_class);

View File

@@ -5,7 +5,6 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::template_ {
@@ -29,11 +28,18 @@ void TemplateAlarmControlPanel::add_sensor(binary_sensor::BinarySensor *sensor,
this->sensor_data_.push_back(sd);
};
// Alarm sensor type strings indexed by AlarmSensorType enum (0-3): DELAYED, INSTANT, DELAYED_FOLLOWER, INSTANT_ALWAYS
PROGMEM_STRING_TABLE(AlarmSensorTypeStrings, "delayed", "instant", "delayed_follower", "instant_always");
static const LogString *sensor_type_to_string(AlarmSensorType type) {
return AlarmSensorTypeStrings::get_log_str(static_cast<uint8_t>(type), 0);
switch (type) {
case ALARM_SENSOR_TYPE_INSTANT:
return LOG_STR("instant");
case ALARM_SENSOR_TYPE_DELAYED_FOLLOWER:
return LOG_STR("delayed_follower");
case ALARM_SENSOR_TYPE_INSTANT_ALWAYS:
return LOG_STR("instant_always");
case ALARM_SENSOR_TYPE_DELAYED:
default:
return LOG_STR("delayed");
}
}
#endif

View File

@@ -26,7 +26,7 @@ enum BinarySensorFlags : uint16_t {
BINARY_SENSOR_MODE_BYPASS_AUTO = 1 << 4,
};
enum AlarmSensorType : uint8_t {
enum AlarmSensorType : uint16_t {
ALARM_SENSOR_TYPE_DELAYED = 0,
ALARM_SENSOR_TYPE_INSTANT,
ALARM_SENSOR_TYPE_DELAYED_FOLLOWER,

View File

@@ -46,7 +46,6 @@ CONFIG_SCHEMA = (
RESTORE_MODES, upper=True
),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.returning_lambda,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.returning_lambda,
cv.Optional(CONF_MODE): cv.returning_lambda,
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(
water_heater.validate_water_heater_mode
@@ -79,14 +78,6 @@ async def to_code(config: ConfigType) -> None:
)
cg.add(var.set_current_temperature_lambda(template_))
if CONF_TARGET_TEMPERATURE in config:
template_ = await cg.process_lambda(
config[CONF_TARGET_TEMPERATURE],
[],
return_type=cg.optional.template(cg.float_),
)
cg.add(var.set_target_temperature_lambda(template_))
if CONF_MODE in config:
template_ = await cg.process_lambda(
config[CONF_MODE],

View File

@@ -16,8 +16,7 @@ void TemplateWaterHeater::setup() {
restore->perform();
}
}
if (!this->current_temperature_f_.has_value() && !this->target_temperature_f_.has_value() &&
!this->mode_f_.has_value())
if (!this->current_temperature_f_.has_value() && !this->mode_f_.has_value())
this->disable_loop();
}
@@ -29,9 +28,6 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
}
traits.set_supports_current_temperature(true);
if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
}
return traits;
}
@@ -46,14 +42,6 @@ void TemplateWaterHeater::loop() {
}
}
auto target_temp = this->target_temperature_f_.call();
if (target_temp.has_value()) {
if (*target_temp != this->target_temperature_) {
this->target_temperature_ = *target_temp;
changed = true;
}
}
auto new_mode = this->mode_f_.call();
if (new_mode.has_value()) {
if (*new_mode != this->mode_) {

View File

@@ -20,9 +20,6 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
template<typename F> void set_current_temperature_lambda(F &&f) {
this->current_temperature_f_.set(std::forward<F>(f));
}
template<typename F> void set_target_temperature_lambda(F &&f) {
this->target_temperature_f_.set(std::forward<F>(f));
}
template<typename F> void set_mode_lambda(F &&f) { this->mode_f_.set(std::forward<F>(f)); }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
@@ -47,7 +44,6 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
// Ordered to minimize padding on 32-bit: 4-byte members first, then smaller
Trigger<> set_trigger_;
TemplateLambda<float> current_temperature_f_;
TemplateLambda<float> target_temperature_f_;
TemplateLambda<water_heater::WaterHeaterMode> mode_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
water_heater::WaterHeaterModeMask supported_modes_;

View File

@@ -13,7 +13,7 @@ from esphome.components.packet_transport import (
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.cpp_generator import literal
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["network"]
@@ -23,12 +23,8 @@ MULTI_CONF = True
udp_ns = cg.esphome_ns.namespace("udp")
UDPComponent = udp_ns.class_("UDPComponent", cg.Component)
UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action)
trigger_argname = "data"
# Listener callback type (non-owning span from UDP component)
listener_args = cg.std_span.template(cg.uint8.operator("const"))
listener_argtype = [(listener_args, trigger_argname)]
# Automation/trigger type (owned vector, safe for deferred actions like delay)
trigger_args = cg.std_vector.template(cg.uint8)
trigger_argname = "data"
trigger_argtype = [(trigger_args, trigger_argname)]
CONF_ADDRESSES = "addresses"
@@ -122,13 +118,7 @@ async def to_code(config):
trigger_id, trigger_argtype, on_receive
)
trigger_lambda = await cg.process_lambda(
trigger.trigger(
cg.std_vector.template(cg.uint8)(
MockObj(trigger_argname).begin(),
MockObj(trigger_argname).end(),
)
),
listener_argtype,
trigger.trigger(literal(trigger_argname)), trigger_argtype
)
cg.add(var.add_listener(trigger_lambda))
cg.add(var.set_should_listen())

View File

@@ -12,7 +12,7 @@ bool UDPTransport::should_send() { return network::is_connected(); }
void UDPTransport::setup() {
PacketTransport::setup();
if (!this->providers_.empty() || this->is_encrypted_()) {
this->parent_->add_listener([this](std::span<const uint8_t> data) { this->process_(data); });
this->parent_->add_listener([this](std::vector<uint8_t> &buf) { this->process_(buf); });
}
}

View File

@@ -103,8 +103,8 @@ void UDPComponent::setup() {
}
void UDPComponent::loop() {
auto buf = std::vector<uint8_t>(MAX_PACKET_SIZE);
if (this->should_listen_) {
std::array<uint8_t, MAX_PACKET_SIZE> buf;
for (;;) {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
auto len = this->listen_socket_->read(buf.data(), buf.size());
@@ -116,9 +116,9 @@ void UDPComponent::loop() {
#endif
if (len <= 0)
break;
size_t packet_len = static_cast<size_t>(len);
ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
buf.resize(len);
ESP_LOGV(TAG, "Received packet of length %zu", len);
this->packet_listeners_.call(buf);
}
}
}

View File

@@ -10,9 +10,7 @@
#ifdef USE_SOCKET_IMPL_LWIP_TCP
#include <WiFiUdp.h>
#endif
#include <array>
#include <initializer_list>
#include <span>
#include <vector>
namespace esphome::udp {
@@ -28,7 +26,7 @@ class UDPComponent : public Component {
void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; }
void set_should_broadcast() { this->should_broadcast_ = true; }
void set_should_listen() { this->should_listen_ = true; }
void add_listener(std::function<void(std::span<const uint8_t>)> &&listener) {
void add_listener(std::function<void(std::vector<uint8_t> &)> &&listener) {
this->packet_listeners_.add(std::move(listener));
}
void setup() override;
@@ -43,7 +41,7 @@ class UDPComponent : public Component {
uint16_t broadcast_port_{};
bool should_broadcast_{};
bool should_listen_{};
CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
CallbackManager<void(std::vector<uint8_t> &)> packet_listeners_{};
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
std::unique_ptr<socket::Socket> broadcast_socket_ = nullptr;

View File

@@ -2,21 +2,12 @@
#include "esphome/core/defines.h"
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome {
namespace update {
static const char *const TAG = "update";
// Update state strings indexed by UpdateState enum (0-3): UNKNOWN, NO UPDATE, UPDATE AVAILABLE, INSTALLING
PROGMEM_STRING_TABLE(UpdateStateStrings, "UNKNOWN", "NO UPDATE", "UPDATE AVAILABLE", "INSTALLING");
const LogString *update_state_to_string(UpdateState state) {
return UpdateStateStrings::get_log_str(static_cast<uint8_t>(state),
static_cast<uint8_t>(UpdateState::UPDATE_STATE_UNKNOWN));
}
void UpdateEntity::publish_state() {
ESP_LOGD(TAG,
"'%s' >>\n"

View File

@@ -27,8 +27,6 @@ enum UpdateState : uint8_t {
UPDATE_STATE_INSTALLING,
};
const LogString *update_state_to_string(UpdateState state);
class UpdateEntity : public EntityBase, public EntityBase_DeviceClass {
public:
void publish_state();

View File

@@ -23,11 +23,17 @@ const LogString *valve_command_to_str(float pos) {
return LOG_STR("UNKNOWN");
}
}
// Valve operation strings indexed by ValveOperation enum (0-2): IDLE, OPENING, CLOSING, plus UNKNOWN
PROGMEM_STRING_TABLE(ValveOperationStrings, "IDLE", "OPENING", "CLOSING", "UNKNOWN");
const LogString *valve_operation_to_str(ValveOperation op) {
return ValveOperationStrings::get_log_str(static_cast<uint8_t>(op), ValveOperationStrings::LAST_INDEX);
switch (op) {
case VALVE_OPERATION_IDLE:
return LOG_STR("IDLE");
case VALVE_OPERATION_OPENING:
return LOG_STR("OPENING");
case VALVE_OPERATION_CLOSING:
return LOG_STR("CLOSING");
default:
return LOG_STR("UNKNOWN");
}
}
Valve::Valve() : position{VALVE_OPEN} {}

View File

@@ -65,7 +65,6 @@ WaterHeaterCall &WaterHeaterCall::set_away(bool away) {
} else {
this->state_ &= ~WATER_HEATER_STATE_AWAY;
}
this->state_mask_ |= WATER_HEATER_STATE_AWAY;
return *this;
}
@@ -75,7 +74,6 @@ WaterHeaterCall &WaterHeaterCall::set_on(bool on) {
} else {
this->state_ &= ~WATER_HEATER_STATE_ON;
}
this->state_mask_ |= WATER_HEATER_STATE_ON;
return *this;
}
@@ -94,11 +92,11 @@ void WaterHeaterCall::perform() {
if (!std::isnan(this->target_temperature_high_)) {
ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_);
}
if (this->state_mask_ & WATER_HEATER_STATE_AWAY) {
ESP_LOGD(TAG, " Away: %s", (this->state_ & WATER_HEATER_STATE_AWAY) ? "YES" : "NO");
if (this->state_ & WATER_HEATER_STATE_AWAY) {
ESP_LOGD(TAG, " Away: YES");
}
if (this->state_mask_ & WATER_HEATER_STATE_ON) {
ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO");
if (this->state_ & WATER_HEATER_STATE_ON) {
ESP_LOGD(TAG, " On: YES");
}
this->parent_->control(*this);
}
@@ -139,17 +137,13 @@ void WaterHeaterCall::validate_() {
this->target_temperature_high_ = NAN;
}
}
if (!traits.get_supports_away_mode()) {
if (this->state_ & WATER_HEATER_STATE_AWAY) {
ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str());
}
if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) {
ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str());
this->state_ &= ~WATER_HEATER_STATE_AWAY;
this->state_mask_ &= ~WATER_HEATER_STATE_AWAY;
}
// If ON/OFF not supported, device is always on - clear the flag silently
if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) {
this->state_ &= ~WATER_HEATER_STATE_ON;
this->state_mask_ &= ~WATER_HEATER_STATE_ON;
}
}
@@ -239,13 +233,25 @@ void WaterHeater::set_visual_target_temperature_step_override(float visual_targe
}
#endif
// Water heater mode strings indexed by WaterHeaterMode enum (0-6): OFF, ECO, ELECTRIC, PERFORMANCE, HIGH_DEMAND,
// HEAT_PUMP, GAS
PROGMEM_STRING_TABLE(WaterHeaterModeStrings, "OFF", "ECO", "ELECTRIC", "PERFORMANCE", "HIGH_DEMAND", "HEAT_PUMP", "GAS",
"UNKNOWN");
const LogString *water_heater_mode_to_string(WaterHeaterMode mode) {
return WaterHeaterModeStrings::get_log_str(static_cast<uint8_t>(mode), WaterHeaterModeStrings::LAST_INDEX);
switch (mode) {
case WATER_HEATER_MODE_OFF:
return LOG_STR("OFF");
case WATER_HEATER_MODE_ECO:
return LOG_STR("ECO");
case WATER_HEATER_MODE_ELECTRIC:
return LOG_STR("ELECTRIC");
case WATER_HEATER_MODE_PERFORMANCE:
return LOG_STR("PERFORMANCE");
case WATER_HEATER_MODE_HIGH_DEMAND:
return LOG_STR("HIGH_DEMAND");
case WATER_HEATER_MODE_HEAT_PUMP:
return LOG_STR("HEAT_PUMP");
case WATER_HEATER_MODE_GAS:
return LOG_STR("GAS");
default:
return LOG_STR("UNKNOWN");
}
}
void WaterHeater::dump_traits_(const char *tag) {

View File

@@ -91,8 +91,6 @@ class WaterHeaterCall {
float get_target_temperature_high() const { return this->target_temperature_high_; }
/// Get state flags value
uint32_t get_state() const { return this->state_; }
/// Get mask of state flags that are being changed
uint32_t get_state_mask() const { return this->state_mask_; }
protected:
void validate_();
@@ -102,7 +100,6 @@ class WaterHeaterCall {
float target_temperature_low_{NAN};
float target_temperature_high_{NAN};
uint32_t state_{0};
uint32_t state_mask_{0};
};
struct WaterHeaterCallInternal : public WaterHeaterCall {
@@ -114,7 +111,6 @@ struct WaterHeaterCallInternal : public WaterHeaterCall {
this->target_temperature_low_ = restore.target_temperature_low_;
this->target_temperature_high_ = restore.target_temperature_high_;
this->state_ = restore.state_;
this->state_mask_ = restore.state_mask_;
return *this;
}
};

View File

@@ -29,10 +29,6 @@
#include "esphome/components/climate/climate.h"
#endif
#ifdef USE_UPDATE
#include "esphome/components/update/update_entity.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -2108,6 +2104,19 @@ std::string WebServer::event_json_(event::Event *obj, StringRef event_type, Json
#endif
#ifdef USE_UPDATE
static const LogString *update_state_to_string(update::UpdateState state) {
switch (state) {
case update::UPDATE_STATE_NO_UPDATE:
return LOG_STR("NO UPDATE");
case update::UPDATE_STATE_AVAILABLE:
return LOG_STR("UPDATE AVAILABLE");
case update::UPDATE_STATE_INSTALLING:
return LOG_STR("INSTALLING");
default:
return LOG_STR("UNKNOWN");
}
}
void WebServer::on_update(update::UpdateEntity *obj) {
this->events_.deferrable_send_state(obj, "state", update_state_json_generator);
}
@@ -2149,7 +2158,7 @@ std::string WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_
JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)),
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)),
obj->update_info.latest_version, start_config);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("current_version")] = obj->update_info.current_version;

View File

@@ -6,7 +6,8 @@
#include <cstring>
#include "multipart_parser.h"
namespace esphome::web_server_idf {
namespace esphome {
namespace web_server_idf {
static const char *const TAG = "multipart";
@@ -248,5 +249,6 @@ std::string str_trim(const std::string &str) {
return str.substr(start, end - start + 1);
}
} // namespace esphome::web_server_idf
} // namespace web_server_idf
} // namespace esphome
#endif // defined(USE_ESP32) && defined(USE_WEBSERVER_OTA)

View File

@@ -10,7 +10,8 @@
#include <string>
#include <utility>
namespace esphome::web_server_idf {
namespace esphome {
namespace web_server_idf {
// Wrapper around zorxx/multipart-parser for ESP-IDF OTA uploads
class MultipartReader {
@@ -80,5 +81,6 @@ bool parse_multipart_boundary(const char *content_type, const char **boundary_st
// Trim whitespace from both ends of a string
std::string str_trim(const std::string &str);
} // namespace esphome::web_server_idf
} // namespace web_server_idf
} // namespace esphome
#endif // defined(USE_ESP32) && defined(USE_WEBSERVER_OTA)

View File

@@ -8,7 +8,8 @@
#include "utils.h"
namespace esphome::web_server_idf {
namespace esphome {
namespace web_server_idf {
static const char *const TAG = "web_server_idf_utils";
@@ -118,5 +119,6 @@ const char *stristr(const char *haystack, const char *needle) {
return nullptr;
}
} // namespace esphome::web_server_idf
} // namespace web_server_idf
} // namespace esphome
#endif // USE_ESP32

View File

@@ -5,7 +5,8 @@
#include <string>
#include "esphome/core/helpers.h"
namespace esphome::web_server_idf {
namespace esphome {
namespace web_server_idf {
/// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space)
/// Returns the new length of the decoded string
@@ -28,5 +29,6 @@ bool str_ncmp_ci(const char *s1, const char *s2, size_t n);
// Case-insensitive string search (like strstr but case-insensitive)
const char *stristr(const char *haystack, const char *needle);
} // namespace esphome::web_server_idf
} // namespace web_server_idf
} // namespace esphome
#endif // USE_ESP32

View File

@@ -30,7 +30,8 @@
#include <cerrno>
#include <sys/socket.h>
namespace esphome::web_server_idf {
namespace esphome {
namespace web_server_idf {
#ifndef HTTPD_409
#define HTTPD_409 "409 Conflict"
@@ -257,6 +258,8 @@ StringRef AsyncWebServerRequest::url_to(std::span<char, URL_BUF_SIZE> buffer) co
return StringRef(buffer.data(), decoded_len);
}
std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); }
void AsyncWebServerRequest::send(AsyncWebServerResponse *response) {
httpd_resp_send(*this, response->get_content_data(), response->get_content_size());
}
@@ -894,6 +897,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c
}
#endif // USE_WEBSERVER_OTA
} // namespace esphome::web_server_idf
} // namespace web_server_idf
} // namespace esphome
#endif // !defined(USE_ESP32)

View File

@@ -121,6 +121,7 @@ class AsyncWebServerRequest {
char buffer[URL_BUF_SIZE];
return std::string(this->url_to(buffer));
}
std::string host() const;
// NOLINTNEXTLINE(readability-identifier-naming)
size_t contentLength() const { return this->req_->content_len; }

View File

@@ -236,23 +236,25 @@ static const char *const TAG = "wifi";
/// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │
/// └──────────────────────────────────────────────────────────────────────┘
// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
if (phase == WiFiRetryPhase::INITIAL_CONNECT)
return LOG_STR("INITIAL_CONNECT");
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
return LOG_STR("INITIAL_CONNECT");
#ifdef USE_WIFI_FAST_CONNECT
if (phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS)
return LOG_STR("FAST_CONNECT_CYCLING");
case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
return LOG_STR("FAST_CONNECT_CYCLING");
#endif
if (phase == WiFiRetryPhase::EXPLICIT_HIDDEN)
return LOG_STR("EXPLICIT_HIDDEN");
if (phase == WiFiRetryPhase::SCAN_CONNECTING)
return LOG_STR("SCAN_CONNECTING");
if (phase == WiFiRetryPhase::RETRY_HIDDEN)
return LOG_STR("RETRY_HIDDEN");
if (phase == WiFiRetryPhase::RESTARTING_ADAPTER)
return LOG_STR("RESTARTING");
return LOG_STR("UNKNOWN");
case WiFiRetryPhase::EXPLICIT_HIDDEN:
return LOG_STR("EXPLICIT_HIDDEN");
case WiFiRetryPhase::SCAN_CONNECTING:
return LOG_STR("SCAN_CONNECTING");
case WiFiRetryPhase::RETRY_HIDDEN:
return LOG_STR("RETRY_HIDDEN");
case WiFiRetryPhase::RESTARTING_ADAPTER:
return LOG_STR("RESTARTING");
default:
return LOG_STR("UNKNOWN");
}
}
bool WiFiComponent::went_through_explicit_hidden_phase_() const {
@@ -1468,14 +1470,6 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
this->notify_connect_state_listeners_();
#endif
#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
// On ESP8266, GOT_IP event may not fire for static IP configurations,
// so notify IP state listeners here as a fallback.
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
this->notify_ip_state_listeners_();
}
#endif
return;
}
@@ -1487,11 +1481,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
}
if (this->error_from_callback_) {
// ESP8266: logging done in callback, listeners deferred via pending_.disconnect
// Other platforms: just log generic failure message
#ifndef USE_ESP8266
ESP_LOGW(TAG, "Connecting to network failed (callback)");
#endif
this->retry_connect();
return;
}
@@ -2212,31 +2202,8 @@ void WiFiComponent::notify_connect_state_listeners_() {
listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
}
}
void WiFiComponent::notify_disconnect_state_listeners_() {
constexpr uint8_t empty_bssid[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), empty_bssid);
}
}
#endif // USE_WIFI_CONNECT_STATE_LISTENERS
#ifdef USE_WIFI_IP_STATE_LISTENERS
void WiFiComponent::notify_ip_state_listeners_() {
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif // USE_WIFI_IP_STATE_LISTENERS
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::notify_scan_results_listeners_() {
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
}
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: not for hidden networks (may not appear in scan)
const WiFiAP *selected = this->get_selected_sta_();

View File

@@ -594,9 +594,6 @@ class WiFiComponent : public Component {
void connect_soon_();
void wifi_loop_();
#ifdef USE_ESP8266
void process_pending_callbacks_();
#endif
bool wifi_mode_(optional<bool> sta, optional<bool> ap);
bool wifi_sta_pre_setup_();
bool wifi_apply_output_power_(float output_power);
@@ -638,16 +635,6 @@ class WiFiComponent : public Component {
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
/// Notify connect state listeners (called after state machine reaches STA_CONNECTED)
void notify_connect_state_listeners_();
/// Notify connect state listeners of disconnection
void notify_disconnect_state_listeners_();
#endif
#ifdef USE_WIFI_IP_STATE_LISTENERS
/// Notify IP state listeners with current addresses
void notify_ip_state_listeners_();
#endif
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
/// Notify scan results listeners with current scan results
void notify_scan_results_listeners_();
#endif
#ifdef USE_ESP8266
@@ -671,13 +658,13 @@ class WiFiComponent : public Component {
void wifi_scan_done_callback_();
#endif
// Large/pointer-aligned members first
FixedVector<WiFiAP> sta_;
std::vector<WiFiSTAPriority> sta_priorities_;
wifi_scan_vector_t<WiFiScanResult> scan_result_;
#ifdef USE_WIFI_AP
WiFiAP ap_;
#endif
float output_power_{NAN};
#ifdef USE_WIFI_IP_STATE_LISTENERS
StaticVector<WiFiIPStateListener *, ESPHOME_WIFI_IP_STATE_LISTENERS> ip_state_listeners_;
#endif
@@ -694,15 +681,6 @@ class WiFiComponent : public Component {
#ifdef USE_WIFI_FAST_CONNECT
ESPPreferenceObject fast_connect_pref_;
#endif
#ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> connect_trigger_;
#endif
#ifdef USE_WIFI_DISCONNECT_TRIGGER
Trigger<> disconnect_trigger_;
#endif
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
SemaphoreHandle_t high_performance_semaphore_{nullptr};
#endif
// Post-connect roaming constants
static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -710,8 +688,7 @@ class WiFiComponent : public Component {
static constexpr int8_t ROAMING_GOOD_RSSI = -49; // Skip scan if signal is excellent
static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3;
// 4-byte members
float output_power_{NAN};
// Group all 32-bit integers together
uint32_t action_started_;
uint32_t last_connected_{0};
uint32_t reboot_timeout_{};
@@ -720,7 +697,7 @@ class WiFiComponent : public Component {
uint32_t ap_timeout_{};
#endif
// 1-byte enums and integers
// Group all 8-bit values together
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2};
@@ -731,39 +708,17 @@ class WiFiComponent : public Component {
// int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS)
int8_t selected_sta_index_{-1};
uint8_t roaming_attempts_{0};
#if USE_NETWORK_IPV6
uint8_t num_ipv6_addresses_{0};
#endif /* USE_NETWORK_IPV6 */
bool error_from_callback_{false};
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
RoamingState roaming_state_{RoamingState::IDLE};
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
#endif
// Bools and bitfields
// Pending listener callbacks deferred from platform callbacks to main loop.
struct {
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Deferred until state machine reaches STA_CONNECTED so wifi.connected
// condition returns true in listener automations.
bool connect_state : 1;
#ifdef USE_ESP8266
// ESP8266: also defer disconnect notification to main loop
bool disconnect : 1;
#endif
#endif
#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS)
bool got_ip : 1;
#endif
#if defined(USE_ESP8266) && defined(USE_WIFI_SCAN_RESULTS_LISTENERS)
bool scan_complete : 1;
#endif
} pending_{};
// Group all boolean values together
bool has_ap_{false};
#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER)
bool handled_connected_state_{false};
#endif
bool error_from_callback_{false};
bool scan_done_{false};
bool ap_setup_{false};
bool ap_started_{false};
@@ -778,10 +733,32 @@ class WiFiComponent : public Component {
bool keep_scan_results_{false};
bool has_completed_scan_after_captive_portal_start_{
false}; // Tracks if we've completed a scan after captive portal started
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
bool skip_cooldown_next_cycle_{false};
bool post_connect_roaming_{true}; // Enabled by default
RoamingState roaming_state_{RoamingState::IDLE};
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
bool is_high_performance_mode_{false};
SemaphoreHandle_t high_performance_semaphore_{nullptr};
#endif
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
// Pending listener notifications deferred until state machine reaches appropriate state.
// Listeners are notified after state transitions complete so conditions like
// wifi.connected return correct values in automations.
// Uses bitfields to minimize memory; more flags may be added as needed.
struct {
bool connect_state : 1; // Notify connect state listeners after STA_CONNECTED
} pending_{};
#endif
#ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> connect_trigger_;
#endif
#ifdef USE_WIFI_DISCONNECT_TRIGGER
Trigger<> disconnect_trigger_;
#endif
private:

View File

@@ -36,7 +36,6 @@ extern "C" {
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/util.h"
namespace esphome::wifi {
@@ -399,82 +398,106 @@ class WiFiMockClass : public ESP8266WiFiGenericClass {
static void _event_callback(void *event) { ESP8266WiFiGenericClass::_eventCallback(event); } // NOLINT
};
// Auth mode strings indexed by AUTH_* constants (0-4), with UNKNOWN at last index
// Static asserts verify the SDK constants are contiguous as expected
static_assert(AUTH_OPEN == 0 && AUTH_WEP == 1 && AUTH_WPA_PSK == 2 && AUTH_WPA2_PSK == 3 && AUTH_WPA_WPA2_PSK == 4,
"AUTH_* constants are not contiguous");
PROGMEM_STRING_TABLE(AuthModeStrings, "OPEN", "WEP", "WPA PSK", "WPA2 PSK", "WPA/WPA2 PSK", "UNKNOWN");
const LogString *get_auth_mode_str(uint8_t mode) {
return AuthModeStrings::get_log_str(mode, AuthModeStrings::LAST_INDEX);
switch (mode) {
case AUTH_OPEN:
return LOG_STR("OPEN");
case AUTH_WEP:
return LOG_STR("WEP");
case AUTH_WPA_PSK:
return LOG_STR("WPA PSK");
case AUTH_WPA2_PSK:
return LOG_STR("WPA2 PSK");
case AUTH_WPA_WPA2_PSK:
return LOG_STR("WPA/WPA2 PSK");
default:
return LOG_STR("UNKNOWN");
}
}
const LogString *get_op_mode_str(uint8_t mode) {
switch (mode) {
case WIFI_OFF:
return LOG_STR("OFF");
case WIFI_STA:
return LOG_STR("STA");
case WIFI_AP:
return LOG_STR("AP");
case WIFI_AP_STA:
return LOG_STR("AP+STA");
default:
return LOG_STR("UNKNOWN");
}
}
// WiFi op mode strings indexed by WIFI_* constants (0-3), with UNKNOWN at last index
static_assert(WIFI_OFF == 0 && WIFI_STA == 1 && WIFI_AP == 2 && WIFI_AP_STA == 3,
"WIFI_* op mode constants are not contiguous");
PROGMEM_STRING_TABLE(OpModeStrings, "OFF", "STA", "AP", "AP+STA", "UNKNOWN");
const LogString *get_op_mode_str(uint8_t mode) { return OpModeStrings::get_log_str(mode, OpModeStrings::LAST_INDEX); }
// Use if-chain instead of switch to avoid jump tables in RODATA (wastes RAM on ESP8266).
// A single switch would generate a sparse lookup table with ~175 default entries, wasting 700 bytes of RAM.
// Even split switches still generate smaller jump tables in RODATA.
const LogString *get_disconnect_reason_str(uint8_t reason) {
if (reason == REASON_AUTH_EXPIRE)
return LOG_STR("Auth Expired");
if (reason == REASON_AUTH_LEAVE)
return LOG_STR("Auth Leave");
if (reason == REASON_ASSOC_EXPIRE)
return LOG_STR("Association Expired");
if (reason == REASON_ASSOC_TOOMANY)
return LOG_STR("Too Many Associations");
if (reason == REASON_NOT_AUTHED)
return LOG_STR("Not Authenticated");
if (reason == REASON_NOT_ASSOCED)
return LOG_STR("Not Associated");
if (reason == REASON_ASSOC_LEAVE)
return LOG_STR("Association Leave");
if (reason == REASON_ASSOC_NOT_AUTHED)
return LOG_STR("Association not Authenticated");
if (reason == REASON_DISASSOC_PWRCAP_BAD)
return LOG_STR("Disassociate Power Cap Bad");
if (reason == REASON_DISASSOC_SUPCHAN_BAD)
return LOG_STR("Disassociate Supported Channel Bad");
if (reason == REASON_IE_INVALID)
return LOG_STR("IE Invalid");
if (reason == REASON_MIC_FAILURE)
return LOG_STR("Mic Failure");
if (reason == REASON_4WAY_HANDSHAKE_TIMEOUT)
return LOG_STR("4-Way Handshake Timeout");
if (reason == REASON_GROUP_KEY_UPDATE_TIMEOUT)
return LOG_STR("Group Key Update Timeout");
if (reason == REASON_IE_IN_4WAY_DIFFERS)
return LOG_STR("IE In 4-Way Handshake Differs");
if (reason == REASON_GROUP_CIPHER_INVALID)
return LOG_STR("Group Cipher Invalid");
if (reason == REASON_PAIRWISE_CIPHER_INVALID)
return LOG_STR("Pairwise Cipher Invalid");
if (reason == REASON_AKMP_INVALID)
return LOG_STR("AKMP Invalid");
if (reason == REASON_UNSUPP_RSN_IE_VERSION)
return LOG_STR("Unsupported RSN IE version");
if (reason == REASON_INVALID_RSN_IE_CAP)
return LOG_STR("Invalid RSN IE Cap");
if (reason == REASON_802_1X_AUTH_FAILED)
return LOG_STR("802.1x Authentication Failed");
if (reason == REASON_CIPHER_SUITE_REJECTED)
return LOG_STR("Cipher Suite Rejected");
if (reason == REASON_BEACON_TIMEOUT)
return LOG_STR("Beacon Timeout");
if (reason == REASON_NO_AP_FOUND)
return LOG_STR("AP Not Found");
if (reason == REASON_AUTH_FAIL)
return LOG_STR("Authentication Failed");
if (reason == REASON_ASSOC_FAIL)
return LOG_STR("Association Failed");
if (reason == REASON_HANDSHAKE_TIMEOUT)
return LOG_STR("Handshake Failed");
return LOG_STR("Unspecified");
/* If this were one big switch statement, GCC would generate a lookup table for it. However, the values of the
* REASON_* constants aren't continuous, and GCC will fill in the gap with the default value -- wasting 4 bytes of RAM
* per entry. As there's ~175 default entries, this wastes 700 bytes of RAM.
*/
if (reason <= REASON_CIPHER_SUITE_REJECTED) { // This must be the last constant with a value <200
switch (reason) {
case REASON_AUTH_EXPIRE:
return LOG_STR("Auth Expired");
case REASON_AUTH_LEAVE:
return LOG_STR("Auth Leave");
case REASON_ASSOC_EXPIRE:
return LOG_STR("Association Expired");
case REASON_ASSOC_TOOMANY:
return LOG_STR("Too Many Associations");
case REASON_NOT_AUTHED:
return LOG_STR("Not Authenticated");
case REASON_NOT_ASSOCED:
return LOG_STR("Not Associated");
case REASON_ASSOC_LEAVE:
return LOG_STR("Association Leave");
case REASON_ASSOC_NOT_AUTHED:
return LOG_STR("Association not Authenticated");
case REASON_DISASSOC_PWRCAP_BAD:
return LOG_STR("Disassociate Power Cap Bad");
case REASON_DISASSOC_SUPCHAN_BAD:
return LOG_STR("Disassociate Supported Channel Bad");
case REASON_IE_INVALID:
return LOG_STR("IE Invalid");
case REASON_MIC_FAILURE:
return LOG_STR("Mic Failure");
case REASON_4WAY_HANDSHAKE_TIMEOUT:
return LOG_STR("4-Way Handshake Timeout");
case REASON_GROUP_KEY_UPDATE_TIMEOUT:
return LOG_STR("Group Key Update Timeout");
case REASON_IE_IN_4WAY_DIFFERS:
return LOG_STR("IE In 4-Way Handshake Differs");
case REASON_GROUP_CIPHER_INVALID:
return LOG_STR("Group Cipher Invalid");
case REASON_PAIRWISE_CIPHER_INVALID:
return LOG_STR("Pairwise Cipher Invalid");
case REASON_AKMP_INVALID:
return LOG_STR("AKMP Invalid");
case REASON_UNSUPP_RSN_IE_VERSION:
return LOG_STR("Unsupported RSN IE version");
case REASON_INVALID_RSN_IE_CAP:
return LOG_STR("Invalid RSN IE Cap");
case REASON_802_1X_AUTH_FAILED:
return LOG_STR("802.1x Authentication Failed");
case REASON_CIPHER_SUITE_REJECTED:
return LOG_STR("Cipher Suite Rejected");
}
}
switch (reason) {
case REASON_BEACON_TIMEOUT:
return LOG_STR("Beacon Timeout");
case REASON_NO_AP_FOUND:
return LOG_STR("AP Not Found");
case REASON_AUTH_FAIL:
return LOG_STR("Authentication Failed");
case REASON_ASSOC_FAIL:
return LOG_STR("Association Failed");
case REASON_HANDSHAKE_TIMEOUT:
return LOG_STR("Handshake Failed");
case REASON_UNSPECIFIED:
default:
return LOG_STR("Unspecified");
}
}
// TODO: This callback runs in ESP8266 system context with limited stack (~2KB).
@@ -496,6 +519,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
// Defer listener notification until state machine reaches STA_CONNECTED
// This ensures wifi.connected condition returns true in listener automations
global_wifi_component->pending_.connect_state = true;
#endif
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = global_wifi_component->get_selected_sta_();
config && config->get_manual_ip().has_value()) {
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(),
global_wifi_component->get_dns_address(0), global_wifi_component->get_dns_address(1));
}
}
#endif
break;
}
@@ -514,9 +547,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
}
s_sta_connected = false;
s_sta_connecting = false;
// IMPORTANT: Set error flag BEFORE notifying listeners.
// This ensures is_connected() returns false during listener callbacks,
// which is critical for proper reconnection logic (e.g., roaming).
global_wifi_component->error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
global_wifi_component->pending_.disconnect = true;
// Notify listeners AFTER setting error flag so they see correct state
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
break;
}
@@ -528,6 +568,8 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
// https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors
if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) {
ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting");
// we can't call retry_connect() from this context, so disconnect immediately
// and notify main thread with error_from_callback_
wifi_station_disconnect();
global_wifi_component->error_from_callback_ = true;
}
@@ -541,8 +583,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
network::IPAddress(&it.gw).str_to(gw_buf), network::IPAddress(&it.mask).str_to(mask_buf));
s_sta_got_ip = true;
#ifdef USE_WIFI_IP_STATE_LISTENERS
// Defer listener callbacks to main loop - system context has limited stack
global_wifi_component->pending_.got_ip = true;
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0),
global_wifi_component->get_dns_address(1));
}
#endif
break;
}
@@ -635,15 +679,21 @@ void WiFiComponent::wifi_pre_setup_() {
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
station_status_t status = wifi_station_get_connect_status();
if (status == STATION_GOT_IP)
return WiFiSTAConnectStatus::CONNECTED;
if (status == STATION_NO_AP_FOUND)
return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND;
if (status == STATION_CONNECT_FAIL || status == STATION_WRONG_PASSWORD)
return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED;
if (status == STATION_CONNECTING)
return WiFiSTAConnectStatus::CONNECTING;
return WiFiSTAConnectStatus::IDLE;
switch (status) {
case STATION_GOT_IP:
return WiFiSTAConnectStatus::CONNECTED;
case STATION_NO_AP_FOUND:
return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND;
;
case STATION_CONNECT_FAIL:
case STATION_WRONG_PASSWORD:
return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED;
case STATION_CONNECTING:
return WiFiSTAConnectStatus::CONNECTING;
case STATION_IDLE:
default:
return WiFiSTAConnectStatus::IDLE;
}
}
bool WiFiComponent::wifi_scan_start_(bool passive) {
static bool first_scan = false;
@@ -748,7 +798,9 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
needs_full ? "" : " (filtered)");
this->scan_done_ = true;
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->pending_.scan_complete = true; // Defer listener callbacks to main loop
for (auto *listener : global_wifi_component->scan_results_listeners_) {
listener->on_wifi_scan_results(global_wifi_component->scan_result_);
}
#endif
}
@@ -935,34 +987,7 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() {
return network::IPAddress(&ip.gw);
}
network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); }
void WiFiComponent::wifi_loop_() { this->process_pending_callbacks_(); }
void WiFiComponent::process_pending_callbacks_() {
// Process callbacks deferred from ESP8266 SDK system context (~2KB stack)
// to main loop context (full stack). Connect state listeners are handled
// by notify_connect_state_listeners_() in the shared state machine code.
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
if (this->pending_.disconnect) {
this->pending_.disconnect = false;
this->notify_disconnect_state_listeners_();
}
#endif
#ifdef USE_WIFI_IP_STATE_LISTENERS
if (this->pending_.got_ip) {
this->pending_.got_ip = false;
this->notify_ip_state_listeners_();
}
#endif
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
if (this->pending_.scan_complete) {
this->pending_.scan_complete = false;
this->notify_scan_results_listeners_();
}
#endif
}
void WiFiComponent::wifi_loop_() {}
} // namespace esphome::wifi
#endif

View File

@@ -753,7 +753,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
@@ -777,7 +779,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
s_sta_connecting = false;
error_from_callback_ = true;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) {
@@ -788,7 +793,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw));
this->got_ipv4_address_ = true;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
#if USE_NETWORK_IPV6
@@ -797,7 +804,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip));
this->num_ipv6_addresses_++;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
#endif /* USE_NETWORK_IPV6 */
@@ -874,7 +883,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {

View File

@@ -468,7 +468,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
s_sta_state = LTWiFiSTAState::CONNECTED;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
}
#endif
@@ -525,7 +527,10 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
}
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
break;
}
@@ -548,14 +553,18 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
network::IPAddress(WiFi.gatewayIP()).str_to(gw_buf));
s_sta_state = LTWiFiSTAState::CONNECTED;
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: {
ESP_LOGV(TAG, "Got IPv6");
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
break;
}
@@ -699,7 +708,9 @@ void WiFiComponent::wifi_scan_done_callback_() {
needs_full ? "" : " (filtered)");
WiFi.scanDelete();
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}

View File

@@ -264,7 +264,9 @@ void WiFiComponent::wifi_loop_() {
ESP_LOGV(TAG, "Scan complete: %zu found, %zu stored%s", s_scan_result_count, this->scan_result_.size(),
needs_full ? "" : " (filtered)");
#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
this->notify_scan_results_listeners_();
for (auto *listener : this->scan_results_listeners_) {
listener->on_wifi_scan_results(this->scan_result_);
}
#endif
}
@@ -288,7 +290,9 @@ void WiFiComponent::wifi_loop_() {
#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
s_sta_had_ip = true;
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
} else if (!is_connected && s_sta_was_connected) {
@@ -297,7 +301,10 @@ void WiFiComponent::wifi_loop_() {
s_sta_had_ip = false;
ESP_LOGV(TAG, "Disconnected");
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
static constexpr uint8_t EMPTY_BSSID[6] = {};
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(StringRef(), EMPTY_BSSID);
}
#endif
}
@@ -315,7 +322,9 @@ void WiFiComponent::wifi_loop_() {
s_sta_had_ip = true;
ESP_LOGV(TAG, "Got IP address");
#ifdef USE_WIFI_IP_STATE_LISTENERS
this->notify_ip_state_listeners_();
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
#endif
}
}

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):

View File

@@ -4,7 +4,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
#include <concepts>
#include <functional>
@@ -57,16 +56,6 @@ template<typename T, typename... X> class TemplatableValue {
this->static_str_ = str;
}
#ifdef USE_ESP8266
// On ESP8266, __FlashStringHelper* is a distinct type from const char*.
// ESPHOME_F(s) expands to F(s) which returns __FlashStringHelper* pointing to PROGMEM.
// Store as FLASH_STRING — value()/is_empty()/ref_or_copy_to() use _P functions
// to access the PROGMEM pointer safely.
TemplatableValue(const __FlashStringHelper *str) requires std::same_as<T, std::string> : type_(FLASH_STRING) {
this->static_str_ = reinterpret_cast<const char *>(str);
}
#endif
template<typename F> TemplatableValue(F value) requires(!std::invocable<F, X...>) : type_(VALUE) {
if constexpr (USE_HEAP_STORAGE) {
this->value_ = new T(std::move(value));
@@ -100,7 +89,7 @@ template<typename T, typename... X> class TemplatableValue {
this->f_ = new std::function<T(X...)>(*other.f_);
} else if (this->type_ == STATELESS_LAMBDA) {
this->stateless_f_ = other.stateless_f_;
} else if (this->type_ == STATIC_STRING || this->type_ == FLASH_STRING) {
} else if (this->type_ == STATIC_STRING) {
this->static_str_ = other.static_str_;
}
}
@@ -119,7 +108,7 @@ template<typename T, typename... X> class TemplatableValue {
other.f_ = nullptr;
} else if (this->type_ == STATELESS_LAMBDA) {
this->stateless_f_ = other.stateless_f_;
} else if (this->type_ == STATIC_STRING || this->type_ == FLASH_STRING) {
} else if (this->type_ == STATIC_STRING) {
this->static_str_ = other.static_str_;
}
other.type_ = NONE;
@@ -152,7 +141,7 @@ template<typename T, typename... X> class TemplatableValue {
} else if (this->type_ == LAMBDA) {
delete this->f_;
}
// STATELESS_LAMBDA/STATIC_STRING/FLASH_STRING/NONE: no cleanup needed (pointers, not heap-allocated)
// STATELESS_LAMBDA/STATIC_STRING/NONE: no cleanup needed (pointers, not heap-allocated)
}
bool has_value() const { return this->type_ != NONE; }
@@ -176,17 +165,6 @@ template<typename T, typename... X> class TemplatableValue {
return std::string(this->static_str_);
}
__builtin_unreachable();
#ifdef USE_ESP8266
case FLASH_STRING:
// PROGMEM pointer — must use _P functions to access on ESP8266
if constexpr (std::same_as<T, std::string>) {
size_t len = strlen_P(this->static_str_);
std::string result(len, '\0');
memcpy_P(result.data(), this->static_str_, len);
return result;
}
__builtin_unreachable();
#endif
case NONE:
default:
return T{};
@@ -208,12 +186,9 @@ template<typename T, typename... X> class TemplatableValue {
}
/// Check if this holds a static string (const char* stored without allocation)
/// The pointer is always directly readable (RAM or flash-mapped).
/// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions).
bool is_static_string() const { return this->type_ == STATIC_STRING; }
/// Get the static string pointer (only valid if is_static_string() returns true)
/// The pointer is always directly readable — FLASH_STRING uses a separate type.
const char *get_static_string() const { return this->static_str_; }
/// Check if the string value is empty without allocating (for std::string specialization).
@@ -225,12 +200,6 @@ template<typename T, typename... X> class TemplatableValue {
return true;
case STATIC_STRING:
return this->static_str_ == nullptr || this->static_str_[0] == '\0';
#ifdef USE_ESP8266
case FLASH_STRING:
// PROGMEM pointer — must use progmem_read_byte on ESP8266
return this->static_str_ == nullptr ||
progmem_read_byte(reinterpret_cast<const uint8_t *>(this->static_str_)) == '\0';
#endif
case VALUE:
return this->value_->empty();
default: // LAMBDA/STATELESS_LAMBDA - must call value()
@@ -240,9 +209,8 @@ template<typename T, typename... X> class TemplatableValue {
/// Get a StringRef to the string value without heap allocation when possible.
/// For STATIC_STRING/VALUE, returns reference to existing data (no allocation).
/// For FLASH_STRING (ESP8266 PROGMEM), copies to provided buffer via _P functions.
/// For LAMBDA/STATELESS_LAMBDA, calls value(), copies to provided buffer, returns ref to buffer.
/// @param lambda_buf Buffer used only for copy cases (must remain valid while StringRef is used).
/// @param lambda_buf Buffer used only for lambda case (must remain valid while StringRef is used).
/// @param lambda_buf_size Size of the buffer.
/// @return StringRef pointing to the string data.
StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const requires std::same_as<T, std::string> {
@@ -253,19 +221,6 @@ template<typename T, typename... X> class TemplatableValue {
if (this->static_str_ == nullptr)
return StringRef();
return StringRef(this->static_str_, strlen(this->static_str_));
#ifdef USE_ESP8266
case FLASH_STRING:
if (this->static_str_ == nullptr)
return StringRef();
{
// PROGMEM pointer — copy to buffer via _P functions
size_t len = strlen_P(this->static_str_);
size_t copy_len = std::min(len, lambda_buf_size - 1);
memcpy_P(lambda_buf, this->static_str_, copy_len);
lambda_buf[copy_len] = '\0';
return StringRef(lambda_buf, copy_len);
}
#endif
case VALUE:
return StringRef(this->value_->data(), this->value_->size());
default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy
@@ -284,7 +239,6 @@ template<typename T, typename... X> class TemplatableValue {
LAMBDA,
STATELESS_LAMBDA,
STATIC_STRING, // For const char* when T is std::string - avoids heap allocation
FLASH_STRING, // PROGMEM pointer on ESP8266; never set on other platforms
} type_;
// For std::string, use heap pointer to minimize union size (4 bytes vs 12+).
// For other types, store value inline as before.
@@ -293,7 +247,7 @@ template<typename T, typename... X> class TemplatableValue {
ValueStorage value_; // T for inline storage, T* for heap storage
std::function<T(X...)> *f_;
T (*stateless_f_)(X...);
const char *static_str_; // For STATIC_STRING and FLASH_STRING types
const char *static_str_; // For STATIC_STRING type
};
};

View File

@@ -295,7 +295,7 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) {
size_t chars = std::min(length, 2 * count);
for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) {
uint8_t val = parse_hex_char(*str);
if (val == INVALID_HEX_CHAR)
if (val > 15)
return 0;
data[i >> 1] = (i & 1) ? data[i >> 1] | val : val << 4;
}

View File

@@ -874,9 +874,6 @@ template<typename T, enable_if_t<std::is_unsigned<T>::value, int> = 0> optional<
}
/// Parse a hex character to its nibble value (0-15), returns 255 on invalid input
/// Returned by parse_hex_char() for non-hex characters.
static constexpr uint8_t INVALID_HEX_CHAR = 255;
constexpr uint8_t parse_hex_char(char c) {
if (c >= '0' && c <= '9')
return c - '0';
@@ -884,7 +881,7 @@ constexpr uint8_t parse_hex_char(char c) {
return c - 'A' + 10;
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
return INVALID_HEX_CHAR;
return 255;
}
/// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase)

View File

@@ -1,11 +1,5 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include "esphome/core/hal.h" // For PROGMEM definition
// Platform-agnostic macros for PROGMEM string handling
// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings
// On other platforms: Use plain strings (no PROGMEM)
@@ -38,80 +32,3 @@ using ProgmemStr = const __FlashStringHelper *;
// Type for pointers to strings (no PROGMEM on non-ESP8266 platforms)
using ProgmemStr = const char *;
#endif
namespace esphome {
/// Helper for C++20 string literal template arguments
template<size_t N> struct FixedString {
char data[N]{};
constexpr FixedString(const char (&str)[N]) {
for (size_t i = 0; i < N; ++i)
data[i] = str[i];
}
constexpr size_t size() const { return N - 1; } // exclude null terminator
};
/// Compile-time string table that packs strings into a single blob with offset lookup.
/// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266.
///
/// Example:
/// PROGMEM_STRING_TABLE(MyStrings, "foo", "bar", "baz");
/// ProgmemStr str = MyStrings::get_progmem_str(idx, MyStrings::LAST_INDEX); // For ArduinoJson
/// const LogString *log_str = MyStrings::get_log_str(idx, MyStrings::LAST_INDEX); // For logging
///
template<FixedString... Strs> struct ProgmemStringTable {
static constexpr size_t COUNT = sizeof...(Strs);
static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1));
/// Generate packed string blob at compile time
static constexpr auto make_blob() {
std::array<char, BLOB_SIZE> result{};
size_t pos = 0;
auto copy = [&](const auto &str) {
for (size_t i = 0; i <= str.size(); ++i)
result[pos++] = str.data[i];
};
(copy(Strs), ...);
return result;
}
/// Generate offset table at compile time (uint8_t limits blob to 255 bytes)
static constexpr auto make_offsets() {
static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string");
static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices");
static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings");
std::array<uint8_t, COUNT> result{};
size_t pos = 0, idx = 0;
((result[idx++] = static_cast<uint8_t>(pos), pos += Strs.size() + 1), ...);
return result;
}
};
// Forward declaration for LogString (defined in log.h)
struct LogString;
/// Instantiate a ProgmemStringTable with PROGMEM storage.
/// Creates: Name::get_progmem_str(idx, fallback), Name::get_log_str(idx, fallback)
/// If idx >= COUNT, returns string at fallback. Use LAST_INDEX for common patterns.
#define PROGMEM_STRING_TABLE(Name, ...) \
struct Name { \
using Table = ::esphome::ProgmemStringTable<__VA_ARGS__>; \
static constexpr size_t COUNT = Table::COUNT; \
static constexpr uint8_t LAST_INDEX = COUNT - 1; \
static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \
static constexpr auto BLOB PROGMEM = Table::make_blob(); \
static constexpr auto OFFSETS PROGMEM = Table::make_offsets(); \
static const char *get_(uint8_t idx, uint8_t fallback) { \
if (idx >= COUNT) \
idx = fallback; \
return &BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]; \
} \
static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \
return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \
} \
static const ::esphome::LogString *get_log_str(uint8_t idx, uint8_t fallback) { \
return reinterpret_cast<const ::esphome::LogString *>(get_(idx, fallback)); \
} \
}
} // namespace esphome

View File

@@ -247,23 +247,6 @@ class LogStringLiteral(Literal):
return f"LOG_STR({cpp_string_escape(self.string)})"
class FlashStringLiteral(Literal):
"""A string literal wrapped in ESPHOME_F() for PROGMEM storage on ESP8266.
On ESP8266, ESPHOME_F(s) expands to F(s) which stores the string in flash (PROGMEM).
On other platforms, ESPHOME_F(s) expands to plain s (no-op).
"""
__slots__ = ("string",)
def __init__(self, string: str) -> None:
super().__init__()
self.string = string
def __str__(self) -> str:
return f"ESPHOME_F({cpp_string_escape(self.string)})"
class IntLiteral(Literal):
__slots__ = ("i",)
@@ -778,10 +761,6 @@ async def templatable(
if is_template(value):
return await process_lambda(value, args, return_type=output_type)
if to_exp is None:
# Automatically wrap static strings in ESPHOME_F() for PROGMEM storage on ESP8266.
# On other platforms ESPHOME_F() is a no-op returning const char*.
if isinstance(value, str) and str(output_type) == "std::string":
return FlashStringLiteral(value)
return value
if isinstance(to_exp, dict):
return to_exp[value]

View File

@@ -12,7 +12,6 @@ std_shared_ptr = std_ns.class_("shared_ptr")
std_string = std_ns.class_("string")
std_string_ref = std_ns.namespace("string &")
std_vector = std_ns.class_("vector")
std_span = std_ns.class_("span")
uint8 = global_ns.namespace("uint8_t")
uint16 = global_ns.namespace("uint16_t")
uint32 = global_ns.namespace("uint32_t")

View File

@@ -317,7 +317,6 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl
# Check if the proc was not forcibly closed
_LOGGER.info("Process exited with return code %s", returncode)
self.write_message({"event": "exit", "code": returncode})
self.close()
def on_close(self) -> None:
# Check if proc exists (if 'start' has been run)
@@ -1054,26 +1053,17 @@ class DownloadBinaryRequestHandler(BaseHandler):
# fallback to type=, but prioritize file=
file_name = self.get_argument("type", None)
file_name = self.get_argument("file", file_name)
if file_name is None or not file_name.strip():
if file_name is None:
self.send_error(400)
return
file_name = file_name.replace("..", "").lstrip("/")
# get requested download name, or build it based on filename
download_name = self.get_argument(
"download",
f"{storage_json.name}-{file_name}",
)
if storage_json.firmware_bin_path is None:
self.send_error(404)
return
base_dir = storage_json.firmware_bin_path.parent.resolve()
path = base_dir.joinpath(file_name).resolve()
try:
path.relative_to(base_dir)
except ValueError:
self.send_error(403)
return
path = storage_json.firmware_bin_path.parent.joinpath(file_name)
if not path.is_file():
args = ["esphome", "idedata", settings.rel_path(configuration)]
@@ -1087,7 +1077,7 @@ class DownloadBinaryRequestHandler(BaseHandler):
found = False
for image in idedata.extra_flash_images:
if image.path.as_posix().endswith(file_name):
if image.path.endswith(file_name):
path = image.path
download_name = file_name
found = True

View File

@@ -1,5 +1,6 @@
import base64
from pathlib import Path
import random
import secrets
import string
from typing import Literal, NotRequired, TypedDict, Unpack
@@ -129,7 +130,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
if len(ap_name) > 32:
ap_name = ap_name_base
kwargs["fallback_name"] = ap_name
kwargs["fallback_psk"] = "".join(secrets.choice(letters) for _ in range(12))
kwargs["fallback_psk"] = "".join(random.choice(letters) for _ in range(12))
base = BASE_CONFIG_FRIENDLY if kwargs.get("friendly_name") else BASE_CONFIG

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
@@ -43,6 +43,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:
@@ -428,6 +449,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)
@@ -435,10 +458,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

View File

@@ -37,7 +37,7 @@ lib_deps_base =
wjtje/qr-code-generator-library@1.7.0 ; qr_code
functionpointer/arduino-MLX90393@1.0.2 ; mlx90393
pavlodn/HaierProtocol@0.9.31 ; haier
esphome/dsmr_parser@1.1.0 ; dsmr
esphome/dsmr_parser@1.0.0 ; dsmr
polargoose/Crypto-no-arduino@0.4.0 ; dsmr
https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps
; This is using the repository until a new release is published to PlatformIO

View File

@@ -23,7 +23,6 @@ resvg-py==0.2.6
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
requests==2.32.5
# esp-idf >= 5.0 requires this
pyparsing >= 3.0

View File

@@ -2270,13 +2270,10 @@ SOURCE_NAMES = {
SOURCE_CLIENT: "SOURCE_CLIENT",
}
RECEIVE_CASES: dict[int, tuple[str, str | None, str]] = {}
RECEIVE_CASES: dict[int, tuple[str, str | None]] = {}
ifdefs: dict[str, str] = {}
# Track messages with no fields (empty messages) for parameter elision
EMPTY_MESSAGES: set[str] = set()
def get_opt(
desc: descriptor.DescriptorProto,
@@ -2507,26 +2504,26 @@ def build_service_message_type(
# Only add ifdef when we're actually generating content
if ifdef is not None:
hout += f"#ifdef {ifdef}\n"
# Generate receive handler and switch case
# Generate receive
func = f"on_{snake}"
has_fields = any(not field.options.deprecated for field in mt.field)
is_empty = not has_fields
if is_empty:
EMPTY_MESSAGES.add(mt.name)
hout += f"virtual void {func}({'' if is_empty else f'const {mt.name} &value'}){{}};\n"
hout += f"virtual void {func}(const {mt.name} &value){{}};\n"
case = ""
if not is_empty:
case += f"{mt.name} msg;\n"
case += f"{mt.name} msg;\n"
# Check if this message has any fields (excluding deprecated ones)
has_fields = any(not field.options.deprecated for field in mt.field)
if has_fields:
# Normal case: decode the message
case += "msg.decode(msg_data, msg_size);\n"
else:
# Empty message optimization: skip decode since there are no fields
case += "// Empty message: no decode needed\n"
if log:
case += "#ifdef HAS_PROTO_MESSAGE_DUMP\n"
if is_empty:
case += f'this->log_receive_message_(LOG_STR("{func}"));\n'
else:
case += f'this->log_receive_message_(LOG_STR("{func}"), msg);\n'
case += f'this->log_receive_message_(LOG_STR("{func}"), msg);\n'
case += "#endif\n"
case += f"this->{func}({'msg' if not is_empty else ''});\n"
case += f"this->{func}(msg);\n"
case += "break;"
# Store the message name and ifdef with the case for later use
RECEIVE_CASES[id_] = (case, ifdef, mt.name)
# Only close ifdef if we opened it
@@ -2842,7 +2839,6 @@ static const char *const TAG = "api.service";
hpp += (
" void log_receive_message_(const LogString *name, const ProtoMessage &msg);\n"
)
hpp += " void log_receive_message_(const LogString *name);\n"
hpp += " public:\n"
hpp += "#endif\n\n"
@@ -2866,9 +2862,6 @@ static const char *const TAG = "api.service";
cpp += " DumpBuffer dump_buf;\n"
cpp += ' ESP_LOGVV(TAG, "%s: %s", LOG_STR_ARG(name), msg.dump_to(dump_buf));\n'
cpp += "}\n"
cpp += f"void {class_name}::log_receive_message_(const LogString *name) {{\n"
cpp += ' ESP_LOGVV(TAG, "%s: {}", LOG_STR_ARG(name));\n'
cpp += "}\n"
cpp += "#endif\n\n"
for mt in file.message_type:
@@ -2936,22 +2929,22 @@ static const char *const TAG = "api.service";
hpp_protected += f"#ifdef {ifdef}\n"
cpp += f"#ifdef {ifdef}\n"
is_empty = inp in EMPTY_MESSAGES
param = "" if is_empty else f"const {inp} &msg"
arg = "" if is_empty else "msg"
hpp_protected += f" void {on_func}(const {inp} &msg) override;\n"
hpp_protected += f" void {on_func}({param}) override;\n"
# For non-void methods, generate a send_ method instead of return-by-value
if is_void:
hpp += f" virtual void {func}({param}) = 0;\n"
hpp += f" virtual void {func}(const {inp} &msg) = 0;\n"
else:
hpp += f" virtual bool send_{func}_response({param}) = 0;\n"
hpp += f" virtual bool send_{func}_response(const {inp} &msg) = 0;\n"
cpp += f"void {class_name}::{on_func}({param}) {{\n"
cpp += f"void {class_name}::{on_func}(const {inp} &msg) {{\n"
# No authentication check here - it's done in read_message
body = ""
if is_void:
body += f"this->{func}({arg});\n"
body += f"this->{func}(msg);\n"
else:
body += f"if (!this->send_{func}_response({arg})) {{\n"
body += f"if (!this->send_{func}_response(msg)) {{\n"
body += " this->on_fatal_error();\n"
body += "}\n"

View File

@@ -25,22 +25,6 @@ display:
lambda: |-
it.circle(64, 64, 50, Color::BLACK);
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-1.54in-G
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-v3

View File

@@ -20,8 +20,6 @@ lvgl:
- id: lvgl_0
default_font: space16
displays: sdl0
top_layer:
- id: lvgl_1
displays: sdl1
on_idle:

View File

@@ -412,7 +412,6 @@ water_heater:
name: "Template Water Heater"
optimistic: true
current_temperature: !lambda "return 42.0f;"
target_temperature: !lambda "return 60.0f;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
supported_modes:
- "OFF"

View File

@@ -8,7 +8,6 @@ import gzip
import json
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -30,7 +29,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state,
)
from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.dashboard.web_server import DashboardSubscriber
from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path
@@ -422,7 +421,7 @@ async def test_download_binary_handler_idedata_fallback(
# Mock idedata response
mock_image = Mock()
mock_image.path = bootloader_file
mock_image.path = str(bootloader_file)
mock_idedata_instance = Mock()
mock_idedata_instance.extra_flash_images = [mock_image]
mock_idedata.return_value = mock_idedata_instance
@@ -529,22 +528,14 @@ async def test_download_binary_handler_subdirectory_file_url_encoded(
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
@pytest.mark.parametrize(
("attack_path", "expected_code"),
"attack_path",
[
pytest.param("../../../secrets.yaml", 403, id="basic_traversal"),
pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"),
pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"),
pytest.param("/etc/passwd", 403, id="absolute_path"),
pytest.param("//etc/passwd", 403, id="double_slash_absolute"),
pytest.param(
"....//secrets.yaml",
# On Windows, Path.resolve() treats "..." and "...." as parent
# traversal (like ".."), so the path escapes base_dir -> 403.
# On Unix, "...." is a literal directory name that stays inside
# base_dir but doesn't exist -> 404.
403 if sys.platform == "win32" else 404,
id="multiple_dots",
),
pytest.param("../../../secrets.yaml", id="basic_traversal"),
pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"),
pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"),
pytest.param("/etc/passwd", id="absolute_path"),
pytest.param("//etc/passwd", id="double_slash_absolute"),
pytest.param("....//secrets.yaml", id="multiple_dots"),
],
)
async def test_download_binary_handler_path_traversal_protection(
@@ -552,14 +543,11 @@ async def test_download_binary_handler_path_traversal_protection(
tmp_path: Path,
mock_storage_json: MagicMock,
attack_path: str,
expected_code: int,
) -> None:
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
Verifies that attempts to escape the build directory via '..' are rejected
using resolve()/relative_to() validation. Tests multiple attack vectors.
Real traversals that escape the base directory get 403. Paths like '....'
that resolve inside the base directory but don't exist get 404.
Verifies that attempts to use '..' in file paths are sanitized to prevent
accessing files outside the build directory. Tests multiple attack vectors.
"""
# Create build structure
build_dir = get_build_path(tmp_path, "test")
@@ -577,67 +565,14 @@ async def test_download_binary_handler_path_traversal_protection(
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
# Mock async_run_system_command so paths that pass validation but don't exist
# return 404 deterministically without spawning a real subprocess.
with (
patch(
"esphome.dashboard.web_server.async_run_system_command",
new_callable=AsyncMock,
return_value=(2, "", ""),
),
pytest.raises(HTTPClientError) as exc_info,
):
# Attempt path traversal attack - should be blocked
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
f"/download.bin?configuration=test.yaml&file={attack_path}",
method="GET",
)
assert exc_info.value.code == expected_code
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_no_firmware_bin_path(
dashboard: DashboardTestHelper,
mock_storage_json: MagicMock,
) -> None:
"""Test that download returns 404 when firmware_bin_path is None.
This covers configs created by StorageJSON.from_wizard() where no
firmware has been compiled yet.
"""
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = None
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=firmware.bin",
method="GET",
)
assert exc_info.value.code == 404
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"])
async def test_download_binary_handler_empty_file_name(
dashboard: DashboardTestHelper,
mock_storage_json: MagicMock,
file_value: str,
) -> None:
"""Test that download returns 400 for empty or whitespace-only file names."""
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = Path("/fake/firmware.bin")
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
f"/download.bin?configuration=test.yaml&file={file_value}",
method="GET",
)
assert exc_info.value.code == 400
# Should get 404 (file not found after sanitization) or 500 (idedata fails)
assert exc_info.value.code in (404, 500)
@pytest.mark.asyncio
@@ -1719,25 +1654,3 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state"
finally:
ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()

View File

@@ -10,7 +10,6 @@ water_heater:
name: Test Boiler
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
# Note: No mode lambda - we want optimistic mode changes to stick
# A mode lambda would override mode changes in loop()
supported_modes:

View File

@@ -93,34 +93,23 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]]
sock.close()
def _get_free_udp_port() -> int:
"""Get a free UDP port by binding to port 0 and releasing."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
@pytest.mark.asyncio
async def test_udp_send_receive(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test UDP component can send and receive messages."""
"""Test UDP component can send messages with multiple addresses configured."""
# Track log lines to verify dump_config output
log_lines: list[str] = []
receive_event = asyncio.Event()
def on_log_line(line: str) -> None:
log_lines.append(line)
if "Received UDP:" in line:
receive_event.set()
async with udp_listener() as (broadcast_port, receiver):
listen_port = _get_free_udp_port()
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port))
async with udp_listener() as (udp_port, receiver):
# Replace placeholders in the config
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port))
async with (
run_compiled(config, line_callback=on_log_line),
@@ -180,19 +169,3 @@ async def test_udp_send_receive(
assert "Address: 127.0.0.2" in log_text, (
f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}"
)
# Test receiving a UDP packet (exercises on_receive with std::span)
test_payload = b"TEST_RECEIVE_UDP"
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
send_sock.sendto(test_payload, ("127.0.0.1", listen_port))
finally:
send_sock.close()
try:
await asyncio.wait_for(receive_event.wait(), timeout=5.0)
except TimeoutError:
pytest.fail(
f"on_receive did not fire. Expected 'Received UDP:' in logs. "
f"Last log lines: {log_lines[-20:]}"
)

View File

@@ -85,9 +85,6 @@ async def test_water_heater_template(
assert initial_state.current_temperature == 45.0, (
f"Expected current temp 45.0, got {initial_state.current_temperature}"
)
assert initial_state.target_temperature == 60.0, (
f"Expected target temp 60.0, got {initial_state.target_temperature}"
)
# Test changing to GAS mode
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS)

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