From 790ac620ab10a228ac54ae0caedf7669650ff9e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:42:12 -0600 Subject: [PATCH 01/37] [web_server_idf] Use C++17 nested namespace style (#13856) --- esphome/components/web_server_idf/multipart.cpp | 6 ++---- esphome/components/web_server_idf/multipart.h | 6 ++---- esphome/components/web_server_idf/utils.cpp | 6 ++---- esphome/components/web_server_idf/utils.h | 6 ++---- esphome/components/web_server_idf/web_server_idf.cpp | 6 ++---- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 2092a41a8e..52dafeb997 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -6,8 +6,7 @@ #include #include "multipart_parser.h" -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { static const char *const TAG = "multipart"; @@ -249,6 +248,5 @@ std::string str_trim(const std::string &str) { return str.substr(start, end - start + 1); } -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // defined(USE_ESP32) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index 8fbe90c4a0..9008be6459 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -10,8 +10,7 @@ #include #include -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { // Wrapper around zorxx/multipart-parser for ESP-IDF OTA uploads class MultipartReader { @@ -81,6 +80,5 @@ 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 web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // defined(USE_ESP32) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index d3c3c3dc55..a1a2793b4a 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -8,8 +8,7 @@ #include "utils.h" -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf_utils"; @@ -119,6 +118,5 @@ const char *stristr(const char *haystack, const char *needle) { return nullptr; } -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // USE_ESP32 diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index ae58f82398..bb0610dac0 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -5,8 +5,7 @@ #include #include "esphome/core/helpers.h" -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { /// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space) /// Returns the new length of the decoded string @@ -29,6 +28,5 @@ 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 web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // USE_ESP32 diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 39e6b7a790..ac7f99bef7 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -30,8 +30,7 @@ #include #include -namespace esphome { -namespace web_server_idf { +namespace esphome::web_server_idf { #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" @@ -895,7 +894,6 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } #endif // USE_WEBSERVER_OTA -} // namespace web_server_idf -} // namespace esphome +} // namespace esphome::web_server_idf #endif // !defined(USE_ESP32) From 22c77866d89da64ab96c830c716225d700b33906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 06:42:26 -0600 Subject: [PATCH 02/37] [e131] Remove unnecessary heap allocation from packet receive loop (#13852) --- esphome/components/e131/e131.cpp | 7 ++----- esphome/components/e131/e131.h | 2 +- esphome/components/e131/e131_packet.cpp | 6 +++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index f11e7f4fe3..4187857901 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -55,7 +55,6 @@ void E131Component::setup() { } void E131Component::loop() { - std::vector payload; E131Packet packet; int universe = 0; uint8_t buf[1460]; @@ -64,11 +63,9 @@ void E131Component::loop() { if (len == -1) { return; } - payload.resize(len); - memmove(&payload[0], buf, len); - if (!this->packet_(payload, universe, packet)) { - ESP_LOGV(TAG, "Invalid packet received of size %zu.", payload.size()); + if (!this->packet_(buf, (size_t) len, universe, packet)) { + ESP_LOGV(TAG, "Invalid packet received of size %zd.", len); return; } diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 831138a545..d4b272eae2 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -38,7 +38,7 @@ class E131Component : public esphome::Component { void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; } protected: - bool packet_(const std::vector &data, int &universe, E131Packet &packet); + bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); bool process_(int universe, const E131Packet &packet); bool join_igmp_groups_(); void join_(int universe); diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index e663a3d0fc..ed081e5758 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -116,11 +116,11 @@ void E131Component::leave_(int universe) { ESP_LOGD(TAG, "Left %d universe for E1.31.", universe); } -bool E131Component::packet_(const std::vector &data, int &universe, E131Packet &packet) { - if (data.size() < E131_MIN_PACKET_SIZE) +bool E131Component::packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet) { + if (len < E131_MIN_PACKET_SIZE) return false; - auto *sbuff = reinterpret_cast(&data[0]); + auto *sbuff = reinterpret_cast(data); if (memcmp(sbuff->acn_id, ACN_ID, sizeof(sbuff->acn_id)) != 0) return false; From 4ef238eb7b563a4a0aec21b4dd492a64543824aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:26:03 -0600 Subject: [PATCH 03/37] [analyze-memory] Attribute third-party library symbols via nm scanning (#13878) --- esphome/analyze_memory/__init__.py | 366 ++++++++++++++++++++++++++++- esphome/analyze_memory/cli.py | 14 +- 2 files changed, 374 insertions(+), 6 deletions(-) diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index d8abc8bafb..bf1bcbfa05 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -43,6 +43,7 @@ _READELF_SECTION_PATTERN = re.compile( # Component category prefixes _COMPONENT_PREFIX_ESPHOME = "[esphome]" _COMPONENT_PREFIX_EXTERNAL = "[external]" +_COMPONENT_PREFIX_LIB = "[lib]" _COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core" _COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api" @@ -56,6 +57,16 @@ SymbolInfoType = tuple[str, int, str] # RAM sections - symbols in these sections consume RAM RAM_SECTIONS = frozenset([".data", ".bss"]) +# nm symbol types for global/weak defined symbols (used for library symbol mapping) +# Only global (uppercase) and weak symbols are safe to use - local symbols (lowercase) +# can have name collisions across compilation units +_NM_DEFINED_GLOBAL_TYPES = frozenset({"T", "D", "B", "R", "W", "V"}) + +# Pattern matching compiler-generated local names that can collide across compilation +# units (e.g., packet$19, buf$20, flag$5261). These are unsafe for name-based lookup. +# Does NOT match mangled C++ names with optimization suffixes (e.g., func$isra$0). +_COMPILER_LOCAL_PATTERN = re.compile(r"^[a-zA-Z_]\w*\$\d+$") + @dataclass class MemorySection: @@ -179,11 +190,19 @@ class MemoryAnalyzer: self._sdk_symbols: list[SDKSymbol] = [] # CSWTCH symbols: list of (name, size, source_file, component) self._cswtch_symbols: list[tuple[str, int, str, str]] = [] + # Library symbol mapping: symbol_name -> library_name + self._lib_symbol_map: dict[str, str] = {} + # Library dir to name mapping: "lib641" -> "espsoftwareserial", + # "espressif__mdns" -> "mdns" + self._lib_hash_to_name: dict[str, str] = {} + # Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns" + self._heuristic_to_lib: dict[str, str] = {} def analyze(self) -> dict[str, ComponentMemory]: """Analyze the ELF file and return component memory usage.""" self._parse_sections() self._parse_symbols() + self._scan_libraries() self._categorize_symbols() self._analyze_cswtch_symbols() self._analyze_sdk_libraries() @@ -328,15 +347,19 @@ class MemoryAnalyzer: # If no component match found, it's core return _COMPONENT_CORE + # Check library symbol map (more accurate than heuristic patterns) + if lib_name := self._lib_symbol_map.get(symbol_name): + return f"{_COMPONENT_PREFIX_LIB}{lib_name}" + # Check against symbol patterns for component, patterns in SYMBOL_PATTERNS.items(): if any(pattern in symbol_name for pattern in patterns): - return component + return self._heuristic_to_lib.get(component, component) # Check against demangled patterns for component, patterns in DEMANGLED_PATTERNS.items(): if any(pattern in demangled for pattern in patterns): - return component + return self._heuristic_to_lib.get(component, component) # Special cases that need more complex logic @@ -384,6 +407,327 @@ class MemoryAnalyzer: return "Other Core" + def _discover_pio_libraries( + self, + libraries: dict[str, list[Path]], + hash_to_name: dict[str, str], + ) -> None: + """Discover PlatformIO third-party libraries from the build directory. + + Scans ``lib/`` directories under ``.pioenvs//`` to find + library names and their ``.a`` archive or ``.o`` file paths. + + Args: + libraries: Dict to populate with library name -> file path list mappings. + Prefers ``.a`` archives when available, falls back to ``.o`` files + (e.g., pioarduino ESP32 Arduino builds only produce ``.o`` files). + hash_to_name: Dict to populate with dir name -> library name mappings + for CSWTCH attribution (e.g., ``lib641`` -> ``espsoftwareserial``). + """ + build_dir = self.elf_path.parent + + for entry in build_dir.iterdir(): + if not entry.is_dir() or not entry.name.startswith("lib"): + continue + # Validate that the suffix after "lib" is a hex hash + hex_part = entry.name[3:] + if not hex_part: + continue + try: + int(hex_part, 16) + except ValueError: + continue + + # Each lib/ directory contains a subdirectory named after the library + for lib_subdir in entry.iterdir(): + if not lib_subdir.is_dir(): + continue + lib_name = lib_subdir.name.lower() + + # Prefer .a archive (lib.a), fall back to .o files + # e.g., lib72a/ESPAsyncTCP/... has lib72a/libESPAsyncTCP.a + archive = entry / f"lib{lib_subdir.name}.a" + if archive.exists(): + file_paths = [archive] + elif archives := list(entry.glob("*.a")): + # Case-insensitive fallback + file_paths = [archives[0]] + else: + # No .a archive (e.g., pioarduino CMake builds) - use .o files + file_paths = sorted(lib_subdir.rglob("*.o")) + + if file_paths: + libraries[lib_name] = file_paths + hash_to_name[entry.name] = lib_name + _LOGGER.debug( + "Discovered PlatformIO library: %s -> %s", + lib_subdir.name, + file_paths[0], + ) + + def _discover_idf_managed_components( + self, + libraries: dict[str, list[Path]], + hash_to_name: dict[str, str], + ) -> None: + """Discover ESP-IDF managed component libraries from the build directory. + + ESP-IDF managed components (from the IDF component registry) use a + ``__`` naming convention. Source files live under + ``managed_components/__/`` and the compiled archives are at + ``esp-idf/__/lib__.a``. + + Args: + libraries: Dict to populate with library name -> file path list mappings. + hash_to_name: Dict to populate with dir name -> library name mappings + for CSWTCH attribution (e.g., ``espressif__mdns`` -> ``mdns``). + """ + build_dir = self.elf_path.parent + + managed_dir = build_dir / "managed_components" + if not managed_dir.is_dir(): + return + + espidf_dir = build_dir / "esp-idf" + + for entry in managed_dir.iterdir(): + if not entry.is_dir() or "__" not in entry.name: + continue + + # Extract the short name: espressif__mdns -> mdns + full_name = entry.name # e.g., espressif__mdns + short_name = full_name.split("__", 1)[1].lower() + + # Find the .a archive under esp-idf/__/ + archive = espidf_dir / full_name / f"lib{full_name}.a" + if archive.exists(): + libraries[short_name] = [archive] + hash_to_name[full_name] = short_name + _LOGGER.debug( + "Discovered IDF managed component: %s -> %s", + short_name, + archive, + ) + + def _build_library_symbol_map( + self, libraries: dict[str, list[Path]] + ) -> dict[str, str]: + """Build a symbol-to-library mapping from library archives or object files. + + Runs ``nm --defined-only`` on each ``.a`` or ``.o`` file to collect + global and weak defined symbols. + + Args: + libraries: Dictionary mapping library name to list of file paths + (``.a`` archives or ``.o`` object files). + + Returns: + Dictionary mapping symbol name to library name. + """ + symbol_map: dict[str, str] = {} + + if not self.nm_path: + return symbol_map + + for lib_name, file_paths in libraries.items(): + result = run_tool( + [self.nm_path, "--defined-only", *(str(p) for p in file_paths)], + timeout=10, + ) + if result is None or result.returncode != 0: + continue + + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 3: + continue + + sym_type = parts[-2] + sym_name = parts[-1] + + # Include global defined symbols (uppercase) and weak symbols (W/V) + if sym_type in _NM_DEFINED_GLOBAL_TYPES: + symbol_map[sym_name] = lib_name + + return symbol_map + + @staticmethod + def _build_heuristic_to_lib_mapping( + library_names: set[str], + ) -> dict[str, str]: + """Build mapping from heuristic pattern categories to discovered libraries. + + Heuristic categories like ``mdns_lib``, ``web_server_lib``, ``async_tcp`` + exist as approximations for library attribution. When we discover the + actual library, symbols matching those heuristics should be redirected + to the ``[lib]`` category instead. + + The mapping is built by checking if the normalized category name + (stripped of ``_lib`` suffix and underscores) appears as a substring + of any discovered library name. + + Examples:: + + mdns_lib -> mdns -> in "mdns" or "esp8266mdns" -> [lib]mdns + web_server_lib -> webserver -> in "espasyncwebserver" -> [lib]espasyncwebserver + async_tcp -> asynctcp -> in "espasynctcp" -> [lib]espasynctcp + + Args: + library_names: Set of discovered library names (lowercase). + + Returns: + Dictionary mapping heuristic category to ``[lib]`` string. + """ + mapping: dict[str, str] = {} + all_categories = set(SYMBOL_PATTERNS) | set(DEMANGLED_PATTERNS) + + for category in all_categories: + base = category.removesuffix("_lib").replace("_", "") + # Collect all libraries whose name contains the base string + candidates = [lib_name for lib_name in library_names if base in lib_name] + if not candidates: + continue + + # Choose a deterministic "best" match: + # 1. Prefer exact name matches over substring matches. + # 2. Among non-exact matches, prefer the shortest library name. + # 3. Break remaining ties lexicographically. + best_lib = min( + candidates, + key=lambda lib_name, _base=base: ( + lib_name != _base, + len(lib_name), + lib_name, + ), + ) + mapping[category] = f"{_COMPONENT_PREFIX_LIB}{best_lib}" + + if mapping: + _LOGGER.debug( + "Heuristic-to-library redirects: %s", + ", ".join(f"{k} -> {v}" for k, v in sorted(mapping.items())), + ) + + return mapping + + def _parse_map_file(self) -> dict[str, str] | None: + """Parse linker map file to build authoritative symbol-to-library mapping. + + The linker map file contains the definitive source attribution for every + symbol, including local/static ones that ``nm`` cannot safely export. + + Map file format (GNU ld):: + + .text._mdns_service_task + 0x400e9fdc 0x65c .pioenvs/env/esp-idf/espressif__mdns/libespressif__mdns.a(mdns.c.o) + + Each section entry has a ``.section.symbol_name`` line followed by an + indented line with address, size, and source path. + + Returns: + Symbol-to-library dict, or ``None`` if no usable map file exists. + """ + map_path = self.elf_path.with_suffix(".map") + if not map_path.exists() or map_path.stat().st_size < 10000: + return None + + _LOGGER.info("Parsing linker map file: %s", map_path.name) + + try: + map_text = map_path.read_text(encoding="utf-8", errors="replace") + except OSError as err: + _LOGGER.warning("Failed to read map file: %s", err) + return None + + symbol_map: dict[str, str] = {} + current_symbol: str | None = None + section_prefixes = (".text.", ".rodata.", ".data.", ".bss.", ".literal.") + + for line in map_text.splitlines(): + # Match section.symbol line: " .text.symbol_name" + # Single space indent, starts with dot + if len(line) > 2 and line[0] == " " and line[1] == ".": + stripped = line.strip() + for prefix in section_prefixes: + if stripped.startswith(prefix): + current_symbol = stripped[len(prefix) :] + break + else: + current_symbol = None + continue + + # Match source attribution line: " 0xADDR 0xSIZE source_path" + if current_symbol is None: + continue + + fields = line.split() + # Skip compiler-generated local names (e.g., packet$19, buf$20) + # that can collide across compilation units + if ( + len(fields) >= 3 + and fields[0].startswith("0x") + and fields[1].startswith("0x") + and not _COMPILER_LOCAL_PATTERN.match(current_symbol) + ): + source_path = fields[2] + # Check if source path contains a known library directory + for dir_key, lib_name in self._lib_hash_to_name.items(): + if dir_key in source_path: + symbol_map[current_symbol] = lib_name + break + + current_symbol = None + + return symbol_map or None + + def _scan_libraries(self) -> None: + """Discover third-party libraries and build symbol mapping. + + Scans both PlatformIO ``lib/`` directories (Arduino builds) and + ESP-IDF ``managed_components/`` (IDF builds) to find library archives. + + Uses the linker map file for authoritative symbol attribution when + available, falling back to ``nm`` scanning with heuristic redirects. + """ + libraries: dict[str, list[Path]] = {} + self._discover_pio_libraries(libraries, self._lib_hash_to_name) + self._discover_idf_managed_components(libraries, self._lib_hash_to_name) + + if not libraries: + _LOGGER.debug("No third-party libraries found") + return + + _LOGGER.info( + "Scanning %d libraries: %s", + len(libraries), + ", ".join(sorted(libraries)), + ) + + # Heuristic redirect catches local symbols (e.g., mdns_task_buffer$14) + # that can't be safely added to the symbol map due to name collisions + self._heuristic_to_lib = self._build_heuristic_to_lib_mapping( + set(libraries.keys()) + ) + + # Try linker map file first (authoritative, includes local symbols) + map_symbols = self._parse_map_file() + if map_symbols is not None: + self._lib_symbol_map = map_symbols + _LOGGER.info( + "Built library symbol map from linker map: %d symbols", + len(self._lib_symbol_map), + ) + return + + # Fall back to nm scanning (global symbols only) + self._lib_symbol_map = self._build_library_symbol_map(libraries) + + _LOGGER.info( + "Built library symbol map from nm: %d symbols from %d libraries", + len(self._lib_symbol_map), + len(libraries), + ) + def _find_object_files_dir(self) -> Path | None: """Find the directory containing object files for this build. @@ -559,9 +903,21 @@ class MemoryAnalyzer: 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 + # Framework/library files - check for PlatformIO library hash dirs + # e.g., lib65b/ESPAsyncTCP/... -> [lib]espasynctcp + if parts and parts[0] in self._lib_hash_to_name: + return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[0]]}" + + # ESP-IDF managed components: managed_components/espressif__mdns/... -> [lib]mdns + if ( + len(parts) >= 2 + and parts[0] == "managed_components" + and parts[1] in self._lib_hash_to_name + ): + return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[1]]}" + + # Other framework/library files - return the first path component + # e.g., FrameworkArduino/... -> FrameworkArduino return parts[0] if parts else source_file def _analyze_cswtch_symbols(self) -> None: diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index bb0eb7723e..dbc19c6b89 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -14,6 +14,7 @@ from . import ( _COMPONENT_CORE, _COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL, + _COMPONENT_PREFIX_LIB, RAM_SECTIONS, MemoryAnalyzer, ) @@ -407,6 +408,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): for name, mem in components if name.startswith(_COMPONENT_PREFIX_EXTERNAL) ] + library_components = [ + (name, mem) + for name, mem in components + if name.startswith(_COMPONENT_PREFIX_LIB) + ] top_esphome_components = sorted( esphome_components, key=lambda x: x[1].flash_total, reverse=True @@ -417,6 +423,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): external_components, key=lambda x: x[1].flash_total, reverse=True ) + # Include all library components + top_library_components = sorted( + library_components, key=lambda x: x[1].flash_total, reverse=True + ) + # Check if API component exists and ensure it's included api_component = None for name, mem in components: @@ -435,10 +446,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): if name in system_components_to_include ] - # Combine all components to analyze: top ESPHome + all external + API if not already included + system components + # Combine all components to analyze: top ESPHome + all external + libraries + API if not already included + system components components_to_analyze = ( list(top_esphome_components) + list(top_external_components) + + list(top_library_components) + system_components ) if api_component and api_component not in components_to_analyze: From 1c60efa4b6591f4b1fad1f76811faec142ab3d3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:30:49 -0600 Subject: [PATCH 04/37] [ota] Use secrets module for OTA authentication cnonce (#13863) --- esphome/espota2.py | 6 ++-- tests/unit_tests/test_espota2.py | 47 +++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 2d90251b38..c342eb4463 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -6,7 +6,7 @@ import hashlib import io import logging from pathlib import Path -import random +import secrets import socket import sys import time @@ -300,8 +300,8 @@ def perform_ota( nonce = nonce_bytes.decode() _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) - # Generate cnonce - cnonce = hash_func(str(random.random()).encode()).hexdigest() + # Generate cnonce matching the hash algorithm's digest size + cnonce = secrets.token_hex(nonce_size // 2) _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) send_check(sock, cnonce, "auth cnonce") diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 1885b769f1..20ba4b1f76 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -18,8 +18,8 @@ from esphome import espota2 from esphome.core import EsphomeError # Test constants -MOCK_RANDOM_VALUE = 0.123456 -MOCK_RANDOM_BYTES = b"0.123456" +MOCK_MD5_CNONCE = "a" * 32 # Mock 32-char hex string from secrets.token_hex(16) +MOCK_SHA256_CNONCE = "b" * 64 # Mock 64-char hex string from secrets.token_hex(32) MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5 MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256 @@ -55,10 +55,18 @@ def mock_time() -> Generator[None]: @pytest.fixture -def mock_random() -> Generator[Mock]: - """Mock random for predictable test values.""" - with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand: - yield mock_rand +def mock_token_hex() -> Generator[Mock]: + """Mock secrets.token_hex for predictable test values.""" + + def _token_hex(nbytes: int) -> str: + if nbytes == 16: + return MOCK_MD5_CNONCE + if nbytes == 32: + return MOCK_SHA256_CNONCE + raise ValueError(f"Unexpected nbytes for token_hex mock: {nbytes}") + + with patch("esphome.espota2.secrets.token_hex", side_effect=_token_hex) as mock: + yield mock @pytest.fixture @@ -236,7 +244,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None: @pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_md5_auth( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test successful OTA with MD5 authentication.""" # Setup socket responses for recv calls @@ -272,8 +280,11 @@ def test_perform_ota_successful_md5_auth( ) ) - # Verify cnonce was sent (MD5 of random.random()) - cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() + # Verify token_hex was called with MD5 digest size + mock_token_hex.assert_called_once_with(16) + + # Verify cnonce was sent + cnonce = MOCK_MD5_CNONCE assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly @@ -366,7 +377,7 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: @pytest.mark.usefixtures("mock_time") def test_perform_ota_md5_auth_wrong_password( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test OTA fails when MD5 authentication is rejected due to wrong password.""" # Setup socket responses for recv calls @@ -390,7 +401,7 @@ def test_perform_ota_md5_auth_wrong_password( @pytest.mark.usefixtures("mock_time") def test_perform_ota_sha256_auth_wrong_password( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test OTA fails when SHA256 authentication is rejected due to wrong password.""" # Setup socket responses for recv calls @@ -603,7 +614,7 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: # Tests for SHA256 authentication @pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_sha256_auth( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test successful OTA with SHA256 authentication.""" # Setup socket responses for recv calls @@ -639,8 +650,11 @@ def test_perform_ota_successful_sha256_auth( ) ) - # Verify cnonce was sent (SHA256 of random.random()) - cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest() + # Verify token_hex was called with SHA256 digest size + mock_token_hex.assert_called_once_with(32) + + # Verify cnonce was sent + cnonce = MOCK_SHA256_CNONCE assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly with SHA256 @@ -654,7 +668,7 @@ def test_perform_ota_successful_sha256_auth( @pytest.mark.usefixtures("mock_time") def test_perform_ota_sha256_fallback_to_md5( - mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock + mock_socket: Mock, mock_file: io.BytesIO, mock_token_hex: Mock ) -> None: """Test SHA256-capable client falls back to MD5 for compatibility.""" # This test verifies the temporary backward compatibility @@ -692,7 +706,8 @@ def test_perform_ota_sha256_fallback_to_md5( ) # But authentication was done with MD5 - cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() + mock_token_hex.assert_called_once_with(16) + cnonce = MOCK_MD5_CNONCE expected_hash = hashlib.md5() expected_hash.update(b"testpass") expected_hash.update(MOCK_MD5_NONCE) From 8b8acb3b2722c8c605dc84a7af9719f7b287ea9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:31:06 -0600 Subject: [PATCH 05/37] [dashboard] Use constant-time comparison for username check (#13865) --- esphome/dashboard/settings.py | 15 +++++--- tests/dashboard/test_settings.py | 66 +++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py index 6035b4a1d6..3b22180b1d 100644 --- a/esphome/dashboard/settings.py +++ b/esphome/dashboard/settings.py @@ -32,7 +32,7 @@ class DashboardSettings: def __init__(self) -> None: """Initialize the dashboard settings.""" self.config_dir: Path = None - self.password_hash: str = "" + self.password_hash: bytes = b"" self.username: str = "" self.using_password: bool = False self.on_ha_addon: bool = False @@ -84,11 +84,14 @@ class DashboardSettings: def check_password(self, username: str, password: str) -> bool: if not self.using_auth: return True - if username != self.username: - return False - - # Compare password in constant running time (to prevent timing attacks) - return hmac.compare_digest(self.password_hash, password_hash(password)) + # Compare in constant running time (to prevent timing attacks) + username_matches = hmac.compare_digest( + username.encode("utf-8"), self.username.encode("utf-8") + ) + password_matches = hmac.compare_digest( + self.password_hash, password_hash(password) + ) + return username_matches and password_matches def rel_path(self, *args: Any) -> Path: """Return a path relative to the ESPHome config folder.""" diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py index 91a8ec70c3..55776ac7c4 100644 --- a/tests/dashboard/test_settings.py +++ b/tests/dashboard/test_settings.py @@ -1,4 +1,4 @@ -"""Tests for dashboard settings Path-related functionality.""" +"""Tests for DashboardSettings (path resolution and authentication).""" from __future__ import annotations @@ -10,6 +10,7 @@ import pytest from esphome.core import CORE from esphome.dashboard.settings import DashboardSettings +from esphome.dashboard.util.password import password_hash @pytest.fixture @@ -221,3 +222,66 @@ def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: # Verify that CORE.config_path itself uses the sentinel file assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" assert not CORE.config_path.exists() # Sentinel file doesn't actually exist + + +@pytest.fixture +def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings: + """Create DashboardSettings with auth configured, based on dashboard_settings.""" + dashboard_settings.username = "admin" + dashboard_settings.using_password = True + dashboard_settings.password_hash = password_hash("correctpassword") + return dashboard_settings + + +def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None: + """Test check_password returns True for correct username and password.""" + assert auth_settings.check_password("admin", "correctpassword") is True + + +def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None: + """Test check_password returns False for wrong password.""" + assert auth_settings.check_password("admin", "wrongpassword") is False + + +def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None: + """Test check_password returns False for wrong username.""" + assert auth_settings.check_password("notadmin", "correctpassword") is False + + +def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None: + """Test check_password returns False when both are wrong.""" + assert auth_settings.check_password("notadmin", "wrongpassword") is False + + +def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None: + """Test check_password returns True when auth is not configured.""" + assert dashboard_settings.check_password("anyone", "anything") is True + + +def test_check_password_non_ascii_username( + dashboard_settings: DashboardSettings, +) -> None: + """Test check_password handles non-ASCII usernames without TypeError.""" + dashboard_settings.username = "\u00e9l\u00e8ve" + dashboard_settings.using_password = True + dashboard_settings.password_hash = password_hash("pass") + assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True + assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False + assert dashboard_settings.check_password("other", "pass") is False + + +def test_check_password_ha_addon_no_password( + dashboard_settings: DashboardSettings, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_password doesn't crash in HA add-on mode without a password. + + In HA add-on mode, using_ha_addon_auth can be True while using_password + is False, leaving password_hash as b"". This must not raise TypeError + in hmac.compare_digest. + """ + monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False) + dashboard_settings.on_ha_addon = True + dashboard_settings.using_password = False + # password_hash stays as default b"" + assert dashboard_settings.check_password("anyone", "anything") is False From 248fc06dacbb4e5cfe861bcda003d2fdb2f7d60b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:44:20 -0600 Subject: [PATCH 06/37] [scheduler] Eliminate heap allocation in full_cleanup_removed_items_ (#13837) --- esphome/core/scheduler.cpp | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 047bf4ef17..6797640f54 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -390,20 +390,19 @@ void Scheduler::full_cleanup_removed_items_() { // 4. No operations inside can block or take other locks, so no deadlock risk LockGuard guard{this->lock_}; - std::vector> valid_items; - - // Move all non-removed items to valid_items, recycle removed ones - for (auto &item : this->items_) { - if (!is_item_removed_(item.get())) { - valid_items.push_back(std::move(item)); + // Compact in-place: move valid items forward, recycle removed ones + size_t write = 0; + for (size_t read = 0; read < this->items_.size(); ++read) { + if (!is_item_removed_(this->items_[read].get())) { + if (write != read) { + this->items_[write] = std::move(this->items_[read]); + } + ++write; } else { - // Recycle removed items - this->recycle_item_main_loop_(std::move(item)); + this->recycle_item_main_loop_(std::move(this->items_[read])); } } - - // Replace items_ with the filtered list - this->items_ = std::move(valid_items); + this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); this->to_remove_ = 0; From c812ac8b29aff567a219a263000494fb4b04b5f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:44:35 -0600 Subject: [PATCH 07/37] [ms8607] Replace set_retry with set_timeout chain to avoid heap allocation (#13842) --- esphome/components/ms8607/ms8607.cpp | 86 ++++++++++++++-------------- esphome/components/ms8607/ms8607.h | 4 ++ 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index 215131eb8e..88a3e6d7dc 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -72,53 +72,55 @@ void MS8607Component::setup() { // I do not know why the device sometimes NACKs the reset command, but // try 3 times in case it's a transitory issue on this boot - this->set_retry( - "reset", 5, 3, - [this](const uint8_t remaining_setup_attempts) { - ESP_LOGD(TAG, "Resetting both I2C addresses: 0x%02X, 0x%02X", this->address_, - this->humidity_device_->get_address()); - // I believe sending the reset command to both addresses is preferable to - // skipping humidity if PT fails for some reason. - // However, only consider the reset successful if they both ACK - bool const pt_successful = this->write_bytes(MS8607_PT_CMD_RESET, nullptr, 0); - bool const h_successful = this->humidity_device_->write_bytes(MS8607_CMD_H_RESET, nullptr, 0); + // Backoff: executes at now, +5ms, +30ms + this->reset_attempts_remaining_ = 3; + this->reset_interval_ = 5; + this->try_reset_(); +} - if (!(pt_successful && h_successful)) { - ESP_LOGE(TAG, "Resetting I2C devices failed"); - if (!pt_successful && !h_successful) { - this->error_code_ = ErrorCode::PTH_RESET_FAILED; - } else if (!pt_successful) { - this->error_code_ = ErrorCode::PT_RESET_FAILED; - } else { - this->error_code_ = ErrorCode::H_RESET_FAILED; - } +void MS8607Component::try_reset_() { + ESP_LOGD(TAG, "Resetting both I2C addresses: 0x%02X, 0x%02X", this->address_, this->humidity_device_->get_address()); + // I believe sending the reset command to both addresses is preferable to + // skipping humidity if PT fails for some reason. + // However, only consider the reset successful if they both ACK + bool const pt_successful = this->write_bytes(MS8607_PT_CMD_RESET, nullptr, 0); + bool const h_successful = this->humidity_device_->write_bytes(MS8607_CMD_H_RESET, nullptr, 0); - if (remaining_setup_attempts > 0) { - this->status_set_error(); - } else { - this->mark_failed(); - } - return RetryResult::RETRY; - } + if (!(pt_successful && h_successful)) { + ESP_LOGE(TAG, "Resetting I2C devices failed"); + if (!pt_successful && !h_successful) { + this->error_code_ = ErrorCode::PTH_RESET_FAILED; + } else if (!pt_successful) { + this->error_code_ = ErrorCode::PT_RESET_FAILED; + } else { + this->error_code_ = ErrorCode::H_RESET_FAILED; + } - this->setup_status_ = SetupStatus::NEEDS_PROM_READ; - this->error_code_ = ErrorCode::NONE; - this->status_clear_error(); + if (--this->reset_attempts_remaining_ > 0) { + uint32_t delay = this->reset_interval_; + this->reset_interval_ *= 5; + this->set_timeout("reset", delay, [this]() { this->try_reset_(); }); + this->status_set_error(); + } else { + this->mark_failed(); + } + return; + } - // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library - this->set_timeout("prom-read", 15, [this]() { - if (this->read_calibration_values_from_prom_()) { - this->setup_status_ = SetupStatus::SUCCESSFUL; - this->status_clear_error(); - } else { - this->mark_failed(); - return; - } - }); + this->setup_status_ = SetupStatus::NEEDS_PROM_READ; + this->error_code_ = ErrorCode::NONE; + this->status_clear_error(); - return RetryResult::DONE; - }, - 5.0f); // executes at now, +5ms, +25ms + // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library + this->set_timeout("prom-read", 15, [this]() { + if (this->read_calibration_values_from_prom_()) { + this->setup_status_ = SetupStatus::SUCCESSFUL; + this->status_clear_error(); + } else { + this->mark_failed(); + return; + } + }); } void MS8607Component::update() { diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 67ce2817fa..ceb3dd22c8 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -44,6 +44,8 @@ class MS8607Component : public PollingComponent, public i2c::I2CDevice { void set_humidity_device(MS8607HumidityDevice *humidity_device) { humidity_device_ = humidity_device; } protected: + /// Attempt to reset both I2C devices, retrying with backoff on failure + void try_reset_(); /** Read and store the Pressure & Temperature calibration settings from the PROM. Intended to be called during setup(), this will set the `failure_reason_` @@ -102,6 +104,8 @@ class MS8607Component : public PollingComponent, public i2c::I2CDevice { enum class SetupStatus; /// Current step in the multi-step & possibly delayed setup() process SetupStatus setup_status_; + uint32_t reset_interval_{5}; + uint8_t reset_attempts_remaining_{0}; }; } // namespace ms8607 From 938a11595dc221cc86586bb778d332d7c60a4753 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:44:50 -0600 Subject: [PATCH 08/37] [speaker] Replace set_retry with set_interval to avoid heap allocation (#13843) --- .../media_player/speaker_media_player.cpp | 42 +++++++++---------- .../media_player/speaker_media_player.h | 5 +++ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 94f555c26e..fdf6bf66cd 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -103,6 +103,20 @@ void SpeakerMediaPlayer::set_playlist_delay_ms(AudioPipelineType pipeline_type, } } +void SpeakerMediaPlayer::stop_and_unpause_media_() { + this->media_pipeline_->stop(); + this->unpause_media_remaining_ = 3; + this->set_interval("unpause_med", 50, [this]() { + if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { + this->cancel_interval("unpause_med"); + this->media_pipeline_->set_pause_state(false); + this->is_paused_ = false; + } else if (--this->unpause_media_remaining_ == 0) { + this->cancel_interval("unpause_med"); + } + }); +} + void SpeakerMediaPlayer::watch_media_commands_() { if (!this->is_ready()) { return; @@ -144,15 +158,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->is_paused_) { // If paused, stop the media pipeline and unpause it after confirming its stopped. This avoids playing a // short segment of the paused file before starting the new one. - this->media_pipeline_->stop(); - this->set_retry("unpause_med", 50, 3, [this](const uint8_t remaining_attempts) { - if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->media_pipeline_->set_pause_state(false); - this->is_paused_ = false; - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); + this->stop_and_unpause_media_(); } else { // Not paused, just directly start the file if (media_command.file.has_value()) { @@ -197,27 +203,21 @@ void SpeakerMediaPlayer::watch_media_commands_() { this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); this->announcement_pipeline_->stop(); - this->set_retry("unpause_ann", 50, 3, [this](const uint8_t remaining_attempts) { + this->unpause_announcement_remaining_ = 3; + this->set_interval("unpause_ann", 50, [this]() { if (this->announcement_pipeline_state_ == AudioPipelineState::STOPPED) { + this->cancel_interval("unpause_ann"); this->announcement_pipeline_->set_pause_state(false); - return RetryResult::DONE; + } else if (--this->unpause_announcement_remaining_ == 0) { + this->cancel_interval("unpause_ann"); } - return RetryResult::RETRY; }); } } else { if (this->media_pipeline_ != nullptr) { this->cancel_timeout("next_media"); this->media_playlist_.clear(); - this->media_pipeline_->stop(); - this->set_retry("unpause_med", 50, 3, [this](const uint8_t remaining_attempts) { - if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->media_pipeline_->set_pause_state(false); - this->is_paused_ = false; - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); + this->stop_and_unpause_media_(); } } diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 722f98ceea..6796fc9c00 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -112,6 +112,9 @@ class SpeakerMediaPlayer : public Component, /// media pipelines are defined. inline bool single_pipeline_() { return (this->media_speaker_ == nullptr); } + /// Stops the media pipeline and polls until stopped to unpause it, avoiding an audible glitch. + void stop_and_unpause_media_(); + // Processes commands from media_control_command_queue_. void watch_media_commands_(); @@ -141,6 +144,8 @@ class SpeakerMediaPlayer : public Component, bool is_paused_{false}; bool is_muted_{false}; + uint8_t unpause_media_remaining_{0}; + uint8_t unpause_announcement_remaining_{0}; // The amount to change the volume on volume up/down commands float volume_increment_; From 66af9980983f4f6fd09e0c7e672231ddd3edfe45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:45:03 -0600 Subject: [PATCH 09/37] [dashboard] Handle malformed Basic Auth headers gracefully (#13866) --- esphome/dashboard/web_server.py | 7 ++- tests/dashboard/test_web_server.py | 83 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 00974bf460..92cab929ef 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -120,8 +120,11 @@ def is_authenticated(handler: BaseHandler) -> bool: if auth_header := handler.request.headers.get("Authorization"): assert isinstance(auth_header, str) if auth_header.startswith("Basic "): - auth_decoded = base64.b64decode(auth_header[6:]).decode() - username, password = auth_decoded.split(":", 1) + try: + auth_decoded = base64.b64decode(auth_header[6:]).decode() + username, password = auth_decoded.split(":", 1) + except (binascii.Error, ValueError, UnicodeDecodeError): + return False return settings.check_password(username, password) return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 9ea7a5164b..daff384515 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -2,6 +2,7 @@ from __future__ import annotations from argparse import Namespace import asyncio +import base64 from collections.abc import Generator from contextlib import asynccontextmanager import gzip @@ -1741,3 +1742,85 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.write_message.assert_not_called() handler.close.assert_not_called() + + +def _make_auth_handler(auth_header: str | None = None) -> Mock: + """Create a mock handler with the given Authorization header.""" + handler = Mock() + handler.request = Mock() + if auth_header is not None: + handler.request.headers = {"Authorization": auth_header} + else: + handler.request.headers = {} + handler.get_secure_cookie = Mock(return_value=None) + return handler + + +@pytest.fixture +def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock: + """Fixture to configure mock dashboard settings with auth enabled.""" + mock_dashboard_settings.using_auth = True + mock_dashboard_settings.on_ha_addon = False + return mock_dashboard_settings + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_malformed_base64() -> None: + """Test that invalid base64 in Authorization header returns False.""" + handler = _make_auth_handler("Basic !!!not-valid-base64!!!") + assert web_server.is_authenticated(handler) is False + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_bad_base64_padding() -> None: + """Test that incorrect base64 padding (binascii.Error) returns False.""" + handler = _make_auth_handler("Basic abc") + assert web_server.is_authenticated(handler) is False + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_invalid_utf8() -> None: + """Test that base64 decoding to invalid UTF-8 returns False.""" + # \xff\xfe is invalid UTF-8 + bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii") + handler = _make_auth_handler(f"Basic {bad_payload}") + assert web_server.is_authenticated(handler) is False + + +@pytest.mark.usefixtures("mock_auth_settings") +def test_is_authenticated_no_colon() -> None: + """Test that base64 payload without ':' separator returns False.""" + no_colon = base64.b64encode(b"nocolonhere").decode("ascii") + handler = _make_auth_handler(f"Basic {no_colon}") + assert web_server.is_authenticated(handler) is False + + +def test_is_authenticated_valid_credentials( + mock_auth_settings: MagicMock, +) -> None: + """Test that valid Basic auth credentials are checked.""" + creds = base64.b64encode(b"admin:secret").decode("ascii") + mock_auth_settings.check_password.return_value = True + handler = _make_auth_handler(f"Basic {creds}") + assert web_server.is_authenticated(handler) is True + mock_auth_settings.check_password.assert_called_once_with("admin", "secret") + + +def test_is_authenticated_wrong_credentials( + mock_auth_settings: MagicMock, +) -> None: + """Test that valid Basic auth with wrong credentials returns False.""" + creds = base64.b64encode(b"admin:wrong").decode("ascii") + mock_auth_settings.check_password.return_value = False + handler = _make_auth_handler(f"Basic {creds}") + assert web_server.is_authenticated(handler) is False + + +def test_is_authenticated_no_auth_configured( + mock_dashboard_settings: MagicMock, +) -> None: + """Test that requests pass when auth is not configured.""" + mock_dashboard_settings.using_auth = False + mock_dashboard_settings.on_ha_addon = False + handler = _make_auth_handler() + assert web_server.is_authenticated(handler) is True From be4e573cc4af420e8f34af8d9c667e11a9662349 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:45:18 -0600 Subject: [PATCH 10/37] [esp32_hosted] Replace set_retry with set_interval to avoid heap allocation (#13844) --- .../update/esp32_hosted_update.cpp | 20 +++++++++++++------ .../esp32_hosted/update/esp32_hosted_update.h | 1 + 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index a7d5f7e3d5..c8e2e879d4 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -27,6 +27,11 @@ static const char *const TAG = "esp32_hosted.update"; // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +// Interval/timeout IDs (uint32_t to avoid string comparison) +constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; +#endif + // Compile-time version string from esp_hosted_host_fw_ver.h macros #define STRINGIFY_(x) #x #define STRINGIFY(x) STRINGIFY_(x) @@ -127,15 +132,18 @@ void Esp32HostedUpdate::setup() { this->status_clear_error(); this->publish_state(); #else - // HTTP mode: retry initial check every 10s until network is ready (max 6 attempts) + // HTTP mode: check every 10s until network is ready (max 6 attempts) // Only if update interval is > 1 minute to avoid redundant checks if (this->get_update_interval() > 60000) { - this->set_retry("initial_check", 10000, 6, [this](uint8_t) { - if (!network::is_connected()) { - return RetryResult::RETRY; + this->initial_check_remaining_ = 6; + this->set_interval(INITIAL_CHECK_INTERVAL_ID, 10000, [this]() { + bool connected = network::is_connected(); + if (--this->initial_check_remaining_ == 0 || connected) { + this->cancel_interval(INITIAL_CHECK_INTERVAL_ID); + if (connected) { + this->check(); + } } - this->check(); - return RetryResult::DONE; }); } #endif diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 7c9645c12a..005e6a6f21 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -44,6 +44,7 @@ class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { // HTTP mode helpers bool fetch_manifest_(); bool stream_firmware_to_coprocessor_(); + uint8_t initial_check_remaining_{0}; #else // Embedded mode members const uint8_t *firmware_data_{nullptr}; From 3cde3dacebf4bc39d36fcd37abc342e198d0500f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 08:45:33 -0600 Subject: [PATCH 11/37] [api] Collapse APIServerConnection intermediary layer (#13872) --- esphome/components/api/api_connection.cpp | 122 ++++++---- esphome/components/api/api_connection.h | 109 +++++---- esphome/components/api/api_pb2_service.cpp | 194 --------------- esphome/components/api/api_pb2_service.h | 261 --------------------- script/api_protobuf/api_protobuf.py | 40 ---- 5 files changed, 141 insertions(+), 585 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index efc3d210b4..ddc24a7e2c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -283,7 +283,7 @@ void APIConnection::loop() { #endif } -bool APIConnection::send_disconnect_response() { +bool APIConnection::send_disconnect_response_() { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response // close will happen on next loop @@ -406,7 +406,7 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c msg.device_class = cover->get_device_class_ref(); return fill_and_encode_entity_info(cover, msg, ListEntitiesCoverResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::cover_command(const CoverCommandRequest &msg) { +void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(cover::Cover, cover, cover) if (msg.has_position) call.set_position(msg.position); @@ -449,7 +449,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supported_preset_modes = &traits.supported_preset_modes(); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::fan_command(const FanCommandRequest &msg) { +void APIConnection::on_fan_command_request(const FanCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(fan::Fan, fan, fan) if (msg.has_state) call.set_state(msg.state); @@ -517,7 +517,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c msg.effects = &effects_list; return fill_and_encode_entity_info(light, msg, ListEntitiesLightResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::light_command(const LightCommandRequest &msg) { +void APIConnection::on_light_command_request(const LightCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(light::LightState, light, light) if (msg.has_state) call.set_state(msg.state); @@ -594,7 +594,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * msg.device_class = a_switch->get_device_class_ref(); return fill_and_encode_entity_info(a_switch, msg, ListEntitiesSwitchResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::switch_command(const SwitchCommandRequest &msg) { +void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) if (msg.state) { @@ -692,7 +692,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supported_swing_modes = &traits.get_supported_swing_modes(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::climate_command(const ClimateCommandRequest &msg) { +void APIConnection::on_climate_command_request(const ClimateCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(climate::Climate, climate, climate) if (msg.has_mode) call.set_mode(static_cast(msg.mode)); @@ -742,7 +742,7 @@ uint16_t APIConnection::try_send_number_info(EntityBase *entity, APIConnection * msg.step = number->traits.get_step(); return fill_and_encode_entity_info(number, msg, ListEntitiesNumberResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::number_command(const NumberCommandRequest &msg) { +void APIConnection::on_number_command_request(const NumberCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(number::Number, number, number) call.set_value(msg.state); call.perform(); @@ -767,7 +767,7 @@ uint16_t APIConnection::try_send_date_info(EntityBase *entity, APIConnection *co ListEntitiesDateResponse msg; return fill_and_encode_entity_info(date, msg, ListEntitiesDateResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::date_command(const DateCommandRequest &msg) { +void APIConnection::on_date_command_request(const DateCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(datetime::DateEntity, date, date) call.set_date(msg.year, msg.month, msg.day); call.perform(); @@ -792,7 +792,7 @@ uint16_t APIConnection::try_send_time_info(EntityBase *entity, APIConnection *co ListEntitiesTimeResponse msg; return fill_and_encode_entity_info(time, msg, ListEntitiesTimeResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::time_command(const TimeCommandRequest &msg) { +void APIConnection::on_time_command_request(const TimeCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(datetime::TimeEntity, time, time) call.set_time(msg.hour, msg.minute, msg.second); call.perform(); @@ -819,7 +819,7 @@ uint16_t APIConnection::try_send_datetime_info(EntityBase *entity, APIConnection ListEntitiesDateTimeResponse msg; return fill_and_encode_entity_info(datetime, msg, ListEntitiesDateTimeResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::datetime_command(const DateTimeCommandRequest &msg) { +void APIConnection::on_date_time_command_request(const DateTimeCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(datetime::DateTimeEntity, datetime, datetime) call.set_datetime(msg.epoch_seconds); call.perform(); @@ -848,7 +848,7 @@ uint16_t APIConnection::try_send_text_info(EntityBase *entity, APIConnection *co msg.pattern = text->traits.get_pattern_ref(); return fill_and_encode_entity_info(text, msg, ListEntitiesTextResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::text_command(const TextCommandRequest &msg) { +void APIConnection::on_text_command_request(const TextCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(text::Text, text, text) call.set_value(msg.state); call.perform(); @@ -874,7 +874,7 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * msg.options = &select->traits.get_options(); return fill_and_encode_entity_info(select, msg, ListEntitiesSelectResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::select_command(const SelectCommandRequest &msg) { +void APIConnection::on_select_command_request(const SelectCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(select::Select, select, select) call.set_option(msg.state.c_str(), msg.state.size()); call.perform(); @@ -888,7 +888,7 @@ uint16_t APIConnection::try_send_button_info(EntityBase *entity, APIConnection * msg.device_class = button->get_device_class_ref(); return fill_and_encode_entity_info(button, msg, ListEntitiesButtonResponse::MESSAGE_TYPE, conn, remaining_size); } -void esphome::api::APIConnection::button_command(const ButtonCommandRequest &msg) { +void esphome::api::APIConnection::on_button_command_request(const ButtonCommandRequest &msg) { ENTITY_COMMAND_GET(button::Button, button, button) button->press(); } @@ -914,7 +914,7 @@ uint16_t APIConnection::try_send_lock_info(EntityBase *entity, APIConnection *co msg.requires_code = a_lock->traits.get_requires_code(); return fill_and_encode_entity_info(a_lock, msg, ListEntitiesLockResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::lock_command(const LockCommandRequest &msg) { +void APIConnection::on_lock_command_request(const LockCommandRequest &msg) { ENTITY_COMMAND_GET(lock::Lock, a_lock, lock) switch (msg.command) { @@ -952,7 +952,7 @@ uint16_t APIConnection::try_send_valve_info(EntityBase *entity, APIConnection *c msg.supports_stop = traits.get_supports_stop(); return fill_and_encode_entity_info(valve, msg, ListEntitiesValveResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::valve_command(const ValveCommandRequest &msg) { +void APIConnection::on_valve_command_request(const ValveCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(valve::Valve, valve, valve) if (msg.has_position) call.set_position(msg.position); @@ -996,7 +996,7 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec return fill_and_encode_entity_info(media_player, msg, ListEntitiesMediaPlayerResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::media_player_command(const MediaPlayerCommandRequest &msg) { +void APIConnection::on_media_player_command_request(const MediaPlayerCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(media_player::MediaPlayer, media_player, media_player) if (msg.has_command) { call.set_command(static_cast(msg.command)); @@ -1063,7 +1063,7 @@ uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection * ListEntitiesCameraResponse msg; return fill_and_encode_entity_info(camera, msg, ListEntitiesCameraResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::camera_image(const CameraImageRequest &msg) { +void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { if (camera::Camera::instance() == nullptr) return; @@ -1092,41 +1092,47 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { #endif #ifdef USE_BLUETOOTH_PROXY -void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { +void APIConnection::on_subscribe_bluetooth_le_advertisements_request( + const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); } -void APIConnection::unsubscribe_bluetooth_le_advertisements() { +void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } -void APIConnection::bluetooth_device_request(const BluetoothDeviceRequest &msg) { +void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); } -void APIConnection::bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) { +void APIConnection::on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_read(msg); } -void APIConnection::bluetooth_gatt_write(const BluetoothGATTWriteRequest &msg) { +void APIConnection::on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_write(msg); } -void APIConnection::bluetooth_gatt_read_descriptor(const BluetoothGATTReadDescriptorRequest &msg) { +void APIConnection::on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_read_descriptor(msg); } -void APIConnection::bluetooth_gatt_write_descriptor(const BluetoothGATTWriteDescriptorRequest &msg) { +void APIConnection::on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_write_descriptor(msg); } -void APIConnection::bluetooth_gatt_get_services(const BluetoothGATTGetServicesRequest &msg) { +void APIConnection::on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_gatt_send_services(msg); } -void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) { +void APIConnection::on_bluetooth_gatt_notify_request(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_() { bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this); return true; } +void APIConnection::on_subscribe_bluetooth_connections_free_request() { + if (!this->send_subscribe_bluetooth_connections_free_response_()) { + this->on_fatal_error(); + } +} -void APIConnection::bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) { +void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); } @@ -1138,7 +1144,7 @@ bool APIConnection::check_voice_assistant_api_connection_() const { voice_assistant::global_voice_assistant->get_api_connection() == this; } -void APIConnection::subscribe_voice_assistant(const SubscribeVoiceAssistantRequest &msg) { +void APIConnection::on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) { if (voice_assistant::global_voice_assistant != nullptr) { voice_assistant::global_voice_assistant->client_subscription(this, msg.subscribe); } @@ -1184,7 +1190,7 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno } } -bool APIConnection::send_voice_assistant_get_configuration_response(const VoiceAssistantConfigurationRequest &msg) { +bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { return this->send_message(resp, VoiceAssistantConfigurationResponse::MESSAGE_TYPE); @@ -1221,8 +1227,13 @@ bool APIConnection::send_voice_assistant_get_configuration_response(const VoiceA resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp, VoiceAssistantConfigurationResponse::MESSAGE_TYPE); } +void APIConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { + if (!this->send_voice_assistant_get_configuration_response_(msg)) { + this->on_fatal_error(); + } +} -void APIConnection::voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) { +void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) { if (this->check_voice_assistant_api_connection_()) { voice_assistant::global_voice_assistant->on_set_configuration(msg.active_wake_words); } @@ -1230,11 +1241,11 @@ void APIConnection::voice_assistant_set_configuration(const VoiceAssistantSetCon #endif #ifdef USE_ZWAVE_PROXY -void APIConnection::zwave_proxy_frame(const ZWaveProxyFrame &msg) { +void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); } -void APIConnection::zwave_proxy_request(const ZWaveProxyRequest &msg) { +void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type); } #endif @@ -1262,7 +1273,7 @@ uint16_t APIConnection::try_send_alarm_control_panel_info(EntityBase *entity, AP return fill_and_encode_entity_info(a_alarm_control_panel, msg, ListEntitiesAlarmControlPanelResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) { +void APIConnection::on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(alarm_control_panel::AlarmControlPanel, a_alarm_control_panel, alarm_control_panel) switch (msg.command) { case enums::ALARM_CONTROL_PANEL_DISARM: @@ -1322,7 +1333,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec return fill_and_encode_entity_info(wh, msg, ListEntitiesWaterHeaterResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::water_heater_command(const WaterHeaterCommandRequest &msg) { +void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(water_heater::WaterHeater, water_heater, water_heater) if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_MODE) call.set_mode(static_cast(msg.mode)); @@ -1364,7 +1375,7 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c #endif #ifdef USE_IR_RF -void APIConnection::infrared_rf_transmit_raw_timings(const InfraredRFTransmitRawTimingsRequest &msg) { +void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { // TODO: When RF is implemented, add a field to the message to distinguish IR vs RF // and dispatch to the appropriate entity type based on that field. #ifdef USE_INFRARED @@ -1418,7 +1429,7 @@ uint16_t APIConnection::try_send_update_info(EntityBase *entity, APIConnection * msg.device_class = update->get_device_class_ref(); return fill_and_encode_entity_info(update, msg, ListEntitiesUpdateResponse::MESSAGE_TYPE, conn, remaining_size); } -void APIConnection::update_command(const UpdateCommandRequest &msg) { +void APIConnection::on_update_command_request(const UpdateCommandRequest &msg) { ENTITY_COMMAND_GET(update::UpdateEntity, update, update) switch (msg.command) { @@ -1469,7 +1480,7 @@ void APIConnection::complete_authentication_() { #endif } -bool APIConnection::send_hello_response(const HelloRequest &msg) { +bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); this->client_api_version_major_ = msg.api_version_major; @@ -1490,12 +1501,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_() { PingResponse resp; return this->send_message(resp, PingResponse::MESSAGE_TYPE); } -bool APIConnection::send_device_info_response() { +bool APIConnection::send_device_info_response_() { DeviceInfoResponse resp{}; resp.name = StringRef(App.get_name()); resp.friendly_name = StringRef(App.get_friendly_name()); @@ -1618,6 +1629,26 @@ bool APIConnection::send_device_info_response() { return this->send_message(resp, DeviceInfoResponse::MESSAGE_TYPE); } +void APIConnection::on_hello_request(const HelloRequest &msg) { + if (!this->send_hello_response_(msg)) { + this->on_fatal_error(); + } +} +void APIConnection::on_disconnect_request() { + if (!this->send_disconnect_response_()) { + this->on_fatal_error(); + } +} +void APIConnection::on_ping_request() { + if (!this->send_ping_response_()) { + this->on_fatal_error(); + } +} +void APIConnection::on_device_info_request() { + if (!this->send_device_info_response_()) { + this->on_fatal_error(); + } +} #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { @@ -1656,7 +1687,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } #endif #ifdef USE_API_USER_DEFINED_ACTIONS -void APIConnection::execute_service(const ExecuteServiceRequest &msg) { +void APIConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { bool found = false; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES // Register the call and get a unique server-generated action_call_id @@ -1722,7 +1753,7 @@ void APIConnection::on_homeassistant_action_response(const HomeassistantActionRe }; #endif #ifdef USE_API_NOISE -bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) { +bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) { NoiseEncryptionSetKeyResponse resp; resp.success = false; @@ -1743,9 +1774,14 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); } +void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { + if (!this->send_noise_encryption_set_key_response_(msg)) { + this->on_fatal_error(); + } +} #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIConnection::subscribe_home_assistant_states() { state_subs_at_ = 0; } +void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; } #endif bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { if (this->flags_.remove) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 935393b2da..ae7f864568 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -47,72 +47,72 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_COVER bool send_cover_state(cover::Cover *cover); - void cover_command(const CoverCommandRequest &msg) override; + void on_cover_command_request(const CoverCommandRequest &msg) override; #endif #ifdef USE_FAN bool send_fan_state(fan::Fan *fan); - void fan_command(const FanCommandRequest &msg) override; + void on_fan_command_request(const FanCommandRequest &msg) override; #endif #ifdef USE_LIGHT bool send_light_state(light::LightState *light); - void light_command(const LightCommandRequest &msg) override; + void on_light_command_request(const LightCommandRequest &msg) override; #endif #ifdef USE_SENSOR bool send_sensor_state(sensor::Sensor *sensor); #endif #ifdef USE_SWITCH bool send_switch_state(switch_::Switch *a_switch); - void switch_command(const SwitchCommandRequest &msg) override; + void on_switch_command_request(const SwitchCommandRequest &msg) override; #endif #ifdef USE_TEXT_SENSOR bool send_text_sensor_state(text_sensor::TextSensor *text_sensor); #endif #ifdef USE_CAMERA void set_camera_state(std::shared_ptr image); - void camera_image(const CameraImageRequest &msg) override; + void on_camera_image_request(const CameraImageRequest &msg) override; #endif #ifdef USE_CLIMATE bool send_climate_state(climate::Climate *climate); - void climate_command(const ClimateCommandRequest &msg) override; + void on_climate_command_request(const ClimateCommandRequest &msg) override; #endif #ifdef USE_NUMBER bool send_number_state(number::Number *number); - void number_command(const NumberCommandRequest &msg) override; + void on_number_command_request(const NumberCommandRequest &msg) override; #endif #ifdef USE_DATETIME_DATE bool send_date_state(datetime::DateEntity *date); - void date_command(const DateCommandRequest &msg) override; + void on_date_command_request(const DateCommandRequest &msg) override; #endif #ifdef USE_DATETIME_TIME bool send_time_state(datetime::TimeEntity *time); - void time_command(const TimeCommandRequest &msg) override; + void on_time_command_request(const TimeCommandRequest &msg) override; #endif #ifdef USE_DATETIME_DATETIME bool send_datetime_state(datetime::DateTimeEntity *datetime); - void datetime_command(const DateTimeCommandRequest &msg) override; + void on_date_time_command_request(const DateTimeCommandRequest &msg) override; #endif #ifdef USE_TEXT bool send_text_state(text::Text *text); - void text_command(const TextCommandRequest &msg) override; + void on_text_command_request(const TextCommandRequest &msg) override; #endif #ifdef USE_SELECT bool send_select_state(select::Select *select); - void select_command(const SelectCommandRequest &msg) override; + void on_select_command_request(const SelectCommandRequest &msg) override; #endif #ifdef USE_BUTTON - void button_command(const ButtonCommandRequest &msg) override; + void on_button_command_request(const ButtonCommandRequest &msg) override; #endif #ifdef USE_LOCK bool send_lock_state(lock::Lock *a_lock); - void lock_command(const LockCommandRequest &msg) override; + void on_lock_command_request(const LockCommandRequest &msg) override; #endif #ifdef USE_VALVE bool send_valve_state(valve::Valve *valve); - void valve_command(const ValveCommandRequest &msg) override; + void on_valve_command_request(const ValveCommandRequest &msg) override; #endif #ifdef USE_MEDIA_PLAYER bool send_media_player_state(media_player::MediaPlayer *media_player); - void media_player_command(const MediaPlayerCommandRequest &msg) override; + void on_media_player_command_request(const MediaPlayerCommandRequest &msg) override; #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES @@ -126,18 +126,18 @@ class APIConnection final : public APIServerConnection { #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES #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 on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; + void on_unsubscribe_bluetooth_le_advertisements_request() override; - void bluetooth_device_request(const BluetoothDeviceRequest &msg) override; - void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) override; - void bluetooth_gatt_write(const BluetoothGATTWriteRequest &msg) override; - void bluetooth_gatt_read_descriptor(const BluetoothGATTReadDescriptorRequest &msg) override; - 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; - void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) override; + void on_bluetooth_device_request(const BluetoothDeviceRequest &msg) override; + void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) override; + void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) override; + void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) override; + void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) override; + void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) override; + void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override; + void on_subscribe_bluetooth_connections_free_request() override; + void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override; #endif #ifdef USE_HOMEASSISTANT_TIME @@ -148,33 +148,33 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_VOICE_ASSISTANT - void subscribe_voice_assistant(const SubscribeVoiceAssistantRequest &msg) override; + void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) override; void on_voice_assistant_response(const VoiceAssistantResponse &msg) override; void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg) override; void on_voice_assistant_audio(const VoiceAssistantAudio &msg) override; void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg) override; void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg) override; - bool send_voice_assistant_get_configuration_response(const VoiceAssistantConfigurationRequest &msg) override; - void voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override; + void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) override; + void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override; #endif #ifdef USE_ZWAVE_PROXY - void zwave_proxy_frame(const ZWaveProxyFrame &msg) override; - void zwave_proxy_request(const ZWaveProxyRequest &msg) override; + void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override; + void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); - void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override; + void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override; #endif #ifdef USE_WATER_HEATER bool send_water_heater_state(water_heater::WaterHeater *water_heater); - void water_heater_command(const WaterHeaterCommandRequest &msg) override; + void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; #endif #ifdef USE_IR_RF - void infrared_rf_transmit_raw_timings(const InfraredRFTransmitRawTimingsRequest &msg) override; + void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) override; void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); #endif @@ -184,7 +184,7 @@ class APIConnection final : public APIServerConnection { #ifdef USE_UPDATE bool send_update_state(update::UpdateEntity *update); - void update_command(const UpdateCommandRequest &msg) override; + void on_update_command_request(const UpdateCommandRequest &msg) override; #endif void on_disconnect_response() override; @@ -198,12 +198,12 @@ class APIConnection final : public APIServerConnection { #ifdef USE_HOMEASSISTANT_TIME 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 { + 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 { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } + void on_subscribe_states_request() 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 @@ -211,19 +211,19 @@ class APIConnection final : public APIServerConnection { this->begin_iterator_(ActiveIterator::INITIAL_STATE); } } - void subscribe_logs(const SubscribeLogsRequest &msg) override { + void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override { this->flags_.log_subscription = msg.level; if (msg.dump_config) App.schedule_dump_config(); } #ifdef USE_API_HOMEASSISTANT_SERVICES - void subscribe_homeassistant_services() override { this->flags_.service_call_subscription = true; } + void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; } #endif #ifdef USE_API_HOMEASSISTANT_STATES - void subscribe_home_assistant_states() override; + void on_subscribe_home_assistant_states_request() override; #endif #ifdef USE_API_USER_DEFINED_ACTIONS - void execute_service(const ExecuteServiceRequest &msg) override; + void on_execute_service_request(const ExecuteServiceRequest &msg) override; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES void send_execute_service_response(uint32_t call_id, bool success, StringRef error_message); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON @@ -233,7 +233,7 @@ class APIConnection final : public APIServerConnection { #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_NOISE - bool send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) override; + void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override; #endif bool is_authenticated() override { @@ -283,6 +283,21 @@ class APIConnection final : public APIServerConnection { // Helper function to handle authentication completion void complete_authentication_(); + // Pattern B helpers: send response and return success/failure + bool send_hello_response_(const HelloRequest &msg); + bool send_disconnect_response_(); + bool send_ping_response_(); + bool send_device_info_response_(); +#ifdef USE_API_NOISE + bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); +#endif +#ifdef USE_BLUETOOTH_PROXY + bool send_subscribe_bluetooth_connections_free_response_(); +#endif +#ifdef USE_VOICE_ASSISTANT + bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg); +#endif + #ifdef USE_CAMERA void try_send_camera_image_(); #endif diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index df66b6eb83..1c04eacc82 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -623,200 +623,6 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } } -void APIServerConnection::on_hello_request(const HelloRequest &msg) { - if (!this->send_hello_response(msg)) { - this->on_fatal_error(); - } -} -void APIServerConnection::on_disconnect_request() { - if (!this->send_disconnect_response()) { - this->on_fatal_error(); - } -} -void APIServerConnection::on_ping_request() { - if (!this->send_ping_response()) { - this->on_fatal_error(); - } -} -void APIServerConnection::on_device_info_request() { - if (!this->send_device_info_response()) { - 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_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(); } -#endif -#ifdef USE_API_HOMEASSISTANT_STATES -void APIServerConnection::on_subscribe_home_assistant_states_request() { this->subscribe_home_assistant_states(); } -#endif -#ifdef USE_API_USER_DEFINED_ACTIONS -void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } -#endif -#ifdef USE_API_NOISE -void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { - if (!this->send_noise_encryption_set_key_response(msg)) { - this->on_fatal_error(); - } -} -#endif -#ifdef USE_BUTTON -void APIServerConnection::on_button_command_request(const ButtonCommandRequest &msg) { this->button_command(msg); } -#endif -#ifdef USE_CAMERA -void APIServerConnection::on_camera_image_request(const CameraImageRequest &msg) { this->camera_image(msg); } -#endif -#ifdef USE_CLIMATE -void APIServerConnection::on_climate_command_request(const ClimateCommandRequest &msg) { this->climate_command(msg); } -#endif -#ifdef USE_COVER -void APIServerConnection::on_cover_command_request(const CoverCommandRequest &msg) { this->cover_command(msg); } -#endif -#ifdef USE_DATETIME_DATE -void APIServerConnection::on_date_command_request(const DateCommandRequest &msg) { this->date_command(msg); } -#endif -#ifdef USE_DATETIME_DATETIME -void APIServerConnection::on_date_time_command_request(const DateTimeCommandRequest &msg) { - this->datetime_command(msg); -} -#endif -#ifdef USE_FAN -void APIServerConnection::on_fan_command_request(const FanCommandRequest &msg) { this->fan_command(msg); } -#endif -#ifdef USE_LIGHT -void APIServerConnection::on_light_command_request(const LightCommandRequest &msg) { this->light_command(msg); } -#endif -#ifdef USE_LOCK -void APIServerConnection::on_lock_command_request(const LockCommandRequest &msg) { this->lock_command(msg); } -#endif -#ifdef USE_MEDIA_PLAYER -void APIServerConnection::on_media_player_command_request(const MediaPlayerCommandRequest &msg) { - this->media_player_command(msg); -} -#endif -#ifdef USE_NUMBER -void APIServerConnection::on_number_command_request(const NumberCommandRequest &msg) { this->number_command(msg); } -#endif -#ifdef USE_SELECT -void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { this->select_command(msg); } -#endif -#ifdef USE_SIREN -void APIServerConnection::on_siren_command_request(const SirenCommandRequest &msg) { this->siren_command(msg); } -#endif -#ifdef USE_SWITCH -void APIServerConnection::on_switch_command_request(const SwitchCommandRequest &msg) { this->switch_command(msg); } -#endif -#ifdef USE_TEXT -void APIServerConnection::on_text_command_request(const TextCommandRequest &msg) { this->text_command(msg); } -#endif -#ifdef USE_DATETIME_TIME -void APIServerConnection::on_time_command_request(const TimeCommandRequest &msg) { this->time_command(msg); } -#endif -#ifdef USE_UPDATE -void APIServerConnection::on_update_command_request(const UpdateCommandRequest &msg) { this->update_command(msg); } -#endif -#ifdef USE_VALVE -void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { this->valve_command(msg); } -#endif -#ifdef USE_WATER_HEATER -void APIServerConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) { - this->water_heater_command(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_subscribe_bluetooth_le_advertisements_request( - const SubscribeBluetoothLEAdvertisementsRequest &msg) { - this->subscribe_bluetooth_le_advertisements(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { - this->bluetooth_device_request(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) { - this->bluetooth_gatt_get_services(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) { - this->bluetooth_gatt_read(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) { - this->bluetooth_gatt_write(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) { - this->bluetooth_gatt_read_descriptor(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) { - this->bluetooth_gatt_write_descriptor(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) { - this->bluetooth_gatt_notify(msg); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_subscribe_bluetooth_connections_free_request() { - if (!this->send_subscribe_bluetooth_connections_free_response()) { - this->on_fatal_error(); - } -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request() { - this->unsubscribe_bluetooth_le_advertisements(); -} -#endif -#ifdef USE_BLUETOOTH_PROXY -void APIServerConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { - this->bluetooth_scanner_set_mode(msg); -} -#endif -#ifdef USE_VOICE_ASSISTANT -void APIServerConnection::on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) { - this->subscribe_voice_assistant(msg); -} -#endif -#ifdef USE_VOICE_ASSISTANT -void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { - if (!this->send_voice_assistant_get_configuration_response(msg)) { - this->on_fatal_error(); - } -} -#endif -#ifdef USE_VOICE_ASSISTANT -void APIServerConnection::on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) { - this->voice_assistant_set_configuration(msg); -} -#endif -#ifdef USE_ALARM_CONTROL_PANEL -void APIServerConnection::on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) { - this->alarm_control_panel_command(msg); -} -#endif -#ifdef USE_ZWAVE_PROXY -void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { this->zwave_proxy_frame(msg); } -#endif -#ifdef USE_ZWAVE_PROXY -void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } -#endif -#ifdef USE_IR_RF -void APIServerConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { - this->infrared_rf_transmit_raw_timings(msg); -} -#endif - void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements for messages switch (msg_type) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index b8c9e4da6f..4dc6ce27d0 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -229,268 +229,7 @@ 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 void subscribe_logs(const SubscribeLogsRequest &msg) = 0; -#ifdef USE_API_HOMEASSISTANT_SERVICES - virtual void subscribe_homeassistant_services() = 0; -#endif -#ifdef USE_API_HOMEASSISTANT_STATES - virtual void subscribe_home_assistant_states() = 0; -#endif -#ifdef USE_API_USER_DEFINED_ACTIONS - virtual void execute_service(const ExecuteServiceRequest &msg) = 0; -#endif -#ifdef USE_API_NOISE - virtual bool send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) = 0; -#endif -#ifdef USE_BUTTON - virtual void button_command(const ButtonCommandRequest &msg) = 0; -#endif -#ifdef USE_CAMERA - virtual void camera_image(const CameraImageRequest &msg) = 0; -#endif -#ifdef USE_CLIMATE - virtual void climate_command(const ClimateCommandRequest &msg) = 0; -#endif -#ifdef USE_COVER - virtual void cover_command(const CoverCommandRequest &msg) = 0; -#endif -#ifdef USE_DATETIME_DATE - virtual void date_command(const DateCommandRequest &msg) = 0; -#endif -#ifdef USE_DATETIME_DATETIME - virtual void datetime_command(const DateTimeCommandRequest &msg) = 0; -#endif -#ifdef USE_FAN - virtual void fan_command(const FanCommandRequest &msg) = 0; -#endif -#ifdef USE_LIGHT - virtual void light_command(const LightCommandRequest &msg) = 0; -#endif -#ifdef USE_LOCK - virtual void lock_command(const LockCommandRequest &msg) = 0; -#endif -#ifdef USE_MEDIA_PLAYER - virtual void media_player_command(const MediaPlayerCommandRequest &msg) = 0; -#endif -#ifdef USE_NUMBER - virtual void number_command(const NumberCommandRequest &msg) = 0; -#endif -#ifdef USE_SELECT - virtual void select_command(const SelectCommandRequest &msg) = 0; -#endif -#ifdef USE_SIREN - virtual void siren_command(const SirenCommandRequest &msg) = 0; -#endif -#ifdef USE_SWITCH - virtual void switch_command(const SwitchCommandRequest &msg) = 0; -#endif -#ifdef USE_TEXT - virtual void text_command(const TextCommandRequest &msg) = 0; -#endif -#ifdef USE_DATETIME_TIME - virtual void time_command(const TimeCommandRequest &msg) = 0; -#endif -#ifdef USE_UPDATE - virtual void update_command(const UpdateCommandRequest &msg) = 0; -#endif -#ifdef USE_VALVE - virtual void valve_command(const ValveCommandRequest &msg) = 0; -#endif -#ifdef USE_WATER_HEATER - virtual void water_heater_command(const WaterHeaterCommandRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_device_request(const BluetoothDeviceRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_gatt_get_services(const BluetoothGATTGetServicesRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_gatt_write(const BluetoothGATTWriteRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_gatt_read_descriptor(const BluetoothGATTReadDescriptorRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_gatt_write_descriptor(const BluetoothGATTWriteDescriptorRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual bool send_subscribe_bluetooth_connections_free_response() = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void unsubscribe_bluetooth_le_advertisements() = 0; -#endif -#ifdef USE_BLUETOOTH_PROXY - virtual void bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) = 0; -#endif -#ifdef USE_VOICE_ASSISTANT - virtual void subscribe_voice_assistant(const SubscribeVoiceAssistantRequest &msg) = 0; -#endif -#ifdef USE_VOICE_ASSISTANT - virtual bool send_voice_assistant_get_configuration_response(const VoiceAssistantConfigurationRequest &msg) = 0; -#endif -#ifdef USE_VOICE_ASSISTANT - virtual void voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) = 0; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) = 0; -#endif -#ifdef USE_ZWAVE_PROXY - virtual void zwave_proxy_frame(const ZWaveProxyFrame &msg) = 0; -#endif -#ifdef USE_ZWAVE_PROXY - virtual void zwave_proxy_request(const ZWaveProxyRequest &msg) = 0; -#endif -#ifdef USE_IR_RF - virtual void infrared_rf_transmit_raw_timings(const InfraredRFTransmitRawTimingsRequest &msg) = 0; -#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_subscribe_logs_request(const SubscribeLogsRequest &msg) override; -#ifdef USE_API_HOMEASSISTANT_SERVICES - void on_subscribe_homeassistant_services_request() override; -#endif -#ifdef USE_API_HOMEASSISTANT_STATES - void on_subscribe_home_assistant_states_request() override; -#endif -#ifdef USE_API_USER_DEFINED_ACTIONS - void on_execute_service_request(const ExecuteServiceRequest &msg) override; -#endif -#ifdef USE_API_NOISE - void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override; -#endif -#ifdef USE_BUTTON - void on_button_command_request(const ButtonCommandRequest &msg) override; -#endif -#ifdef USE_CAMERA - void on_camera_image_request(const CameraImageRequest &msg) override; -#endif -#ifdef USE_CLIMATE - void on_climate_command_request(const ClimateCommandRequest &msg) override; -#endif -#ifdef USE_COVER - void on_cover_command_request(const CoverCommandRequest &msg) override; -#endif -#ifdef USE_DATETIME_DATE - void on_date_command_request(const DateCommandRequest &msg) override; -#endif -#ifdef USE_DATETIME_DATETIME - void on_date_time_command_request(const DateTimeCommandRequest &msg) override; -#endif -#ifdef USE_FAN - void on_fan_command_request(const FanCommandRequest &msg) override; -#endif -#ifdef USE_LIGHT - void on_light_command_request(const LightCommandRequest &msg) override; -#endif -#ifdef USE_LOCK - void on_lock_command_request(const LockCommandRequest &msg) override; -#endif -#ifdef USE_MEDIA_PLAYER - void on_media_player_command_request(const MediaPlayerCommandRequest &msg) override; -#endif -#ifdef USE_NUMBER - void on_number_command_request(const NumberCommandRequest &msg) override; -#endif -#ifdef USE_SELECT - void on_select_command_request(const SelectCommandRequest &msg) override; -#endif -#ifdef USE_SIREN - void on_siren_command_request(const SirenCommandRequest &msg) override; -#endif -#ifdef USE_SWITCH - void on_switch_command_request(const SwitchCommandRequest &msg) override; -#endif -#ifdef USE_TEXT - void on_text_command_request(const TextCommandRequest &msg) override; -#endif -#ifdef USE_DATETIME_TIME - void on_time_command_request(const TimeCommandRequest &msg) override; -#endif -#ifdef USE_UPDATE - void on_update_command_request(const UpdateCommandRequest &msg) override; -#endif -#ifdef USE_VALVE - void on_valve_command_request(const ValveCommandRequest &msg) override; -#endif -#ifdef USE_WATER_HEATER - void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_device_request(const BluetoothDeviceRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_subscribe_bluetooth_connections_free_request() override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_unsubscribe_bluetooth_le_advertisements_request() override; -#endif -#ifdef USE_BLUETOOTH_PROXY - void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override; -#endif -#ifdef USE_VOICE_ASSISTANT - void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) override; -#endif -#ifdef USE_VOICE_ASSISTANT - void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) override; -#endif -#ifdef USE_VOICE_ASSISTANT - void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override; -#endif -#ifdef USE_ZWAVE_PROXY - void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override; -#endif -#ifdef USE_ZWAVE_PROXY - void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; -#endif -#ifdef USE_IR_RF - void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) override; -#endif void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 5fbc1137a8..8673996a25 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2906,7 +2906,6 @@ static const char *const TAG = "api.service"; class_name = "APIServerConnection" hpp += "\n" hpp += f"class {class_name} : public {class_name}Base {{\n" - hpp += " public:\n" hpp_protected = "" cpp += "\n" @@ -2914,14 +2913,8 @@ static const char *const TAG = "api.service"; message_auth_map: dict[str, bool] = {} message_conn_map: dict[str, bool] = {} - m = serv.method[0] for m in serv.method: - func = m.name inp = m.input_type[1:] - ret = m.output_type[1:] - is_void = ret == "void" - snake = camel_to_snake(inp) - on_func = f"on_{snake}" needs_conn = get_opt(m, pb.needs_setup_connection, True) needs_auth = get_opt(m, pb.needs_authentication, True) @@ -2929,39 +2922,6 @@ static const char *const TAG = "api.service"; message_auth_map[inp] = needs_auth message_conn_map[inp] = needs_conn - ifdef = message_ifdef_map.get(inp, ifdefs.get(inp)) - - if ifdef is not None: - hpp += f"#ifdef {ifdef}\n" - 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}({param}) override;\n" - if is_void: - hpp += f" virtual void {func}({param}) = 0;\n" - else: - hpp += f" virtual bool send_{func}_response({param}) = 0;\n" - - cpp += f"void {class_name}::{on_func}({param}) {{\n" - body = "" - if is_void: - body += f"this->{func}({arg});\n" - else: - body += f"if (!this->send_{func}_response({arg})) {{\n" - body += " this->on_fatal_error();\n" - body += "}\n" - - cpp += indent(body) + "\n" + "}\n" - - if ifdef is not None: - hpp += "#endif\n" - hpp_protected += "#endif\n" - cpp += "#endif\n" - # Generate optimized read_message with authentication checking # Categorize messages by their authentication requirements no_conn_ids: set[int] = set() From c28c97fbaf15c4ce353317e873054fb00a1d7a6d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 9 Feb 2026 09:19:00 -0600 Subject: [PATCH 12/37] [mixer] Refactor for stability and to support Sendspin (#12253) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mixer/speaker/__init__.py | 10 +- esphome/components/mixer/speaker/automation.h | 4 +- .../mixer/speaker/mixer_speaker.cpp | 438 +++++++++++++----- .../components/mixer/speaker/mixer_speaker.h | 53 ++- 4 files changed, 364 insertions(+), 141 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index c4069851af..a3025d7121 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, esp32, socket, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -61,7 +61,7 @@ def _set_stream_limits(config): def _validate_source_speaker(config): fconf = fv.full_config.get() - # Get ID for the output speaker and add it to the source speakrs config to easily inherit properties + # Get ID for the output speaker and add it to the source speakers config to easily inherit properties path = fconf.get_path_for_id(config[CONF_ID])[:-3] path.append(CONF_OUTPUT_SPEAKER) output_speaker_id = fconf.get_config_for_path(path) @@ -111,6 +111,9 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config): + # Enable wake_loop_threadsafe for immediate command processing from other tasks + socket.require_wake_loop_threadsafe() + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -127,6 +130,9 @@ async def to_code(config): "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True ) + # Initialize FixedVector with exact count of source speakers + cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS]))) + for speaker_config in config[CONF_SOURCE_SPEAKERS]: source_speaker = cg.new_Pvariable(speaker_config[CONF_ID]) diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h index 2fb2f49373..4fa3853583 100644 --- a/esphome/components/mixer/speaker/automation.h +++ b/esphome/components/mixer/speaker/automation.h @@ -8,8 +8,8 @@ namespace esphome { namespace mixer_speaker { template class DuckingApplyAction : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, decibel_reduction) - TEMPLATABLE_VALUE(uint32_t, duration) + TEMPLATABLE_VALUE(uint8_t, decibel_reduction); + TEMPLATABLE_VALUE(uint32_t, duration); void play(const Ts &...x) override { this->parent_->apply_ducking(this->decibel_reduction_.value(x...), this->duration_.value(x...)); } diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 043b629cf1..100acbebc3 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -2,11 +2,13 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include namespace esphome { @@ -14,6 +16,7 @@ namespace mixer_speaker { static const UBaseType_t MIXER_TASK_PRIORITY = 10; +static const uint32_t STOPPING_TIMEOUT_MS = 5000; static const uint32_t TRANSFER_BUFFER_DURATION_MS = 50; static const uint32_t TASK_DELAY_MS = 25; @@ -27,21 +30,53 @@ static const char *const TAG = "speaker_mixer"; // Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB // dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) // float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15) -static const std::vector DECIBEL_REDUCTION_TABLE = { +static const std::array DECIBEL_REDUCTION_TABLE = { 32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183, 4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731, 651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103}; -enum MixerEventGroupBits : uint32_t { - COMMAND_STOP = (1 << 0), // stops the mixer task - STATE_STARTING = (1 << 10), - STATE_RUNNING = (1 << 11), - STATE_STOPPING = (1 << 12), - STATE_STOPPED = (1 << 13), - ERR_ESP_NO_MEM = (1 << 19), - ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +// Event bits for SourceSpeaker command processing +enum SourceSpeakerEventBits : uint32_t { + SOURCE_SPEAKER_COMMAND_START = (1 << 0), + SOURCE_SPEAKER_COMMAND_STOP = (1 << 1), + SOURCE_SPEAKER_COMMAND_FINISH = (1 << 2), }; +// Event bits for mixer task control and state +enum MixerTaskEventBits : uint32_t { + MIXER_TASK_COMMAND_START = (1 << 0), + MIXER_TASK_COMMAND_STOP = (1 << 1), + MIXER_TASK_STATE_STARTING = (1 << 10), + MIXER_TASK_STATE_RUNNING = (1 << 11), + MIXER_TASK_STATE_STOPPING = (1 << 12), + MIXER_TASK_STATE_STOPPED = (1 << 13), + MIXER_TASK_ERR_ESP_NO_MEM = (1 << 19), + MIXER_TASK_ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { + uint32_t current = var.load(std::memory_order_acquire); + uint32_t subtracted = 0; + if (current > 0) { + uint32_t new_value; + do { + subtracted = std::min(amount, current); + new_value = current - subtracted; + } while (!var.compare_exchange_weak(current, new_value, std::memory_order_release, std::memory_order_acquire)); + } + return subtracted; +} + +static bool create_event_group(EventGroupHandle_t &event_group, Component *component) { + event_group = xEventGroupCreate(); + if (event_group == nullptr) { + ESP_LOGE(TAG, "Failed to create event group"); + component->mark_failed(); + return false; + } + return true; +} + void SourceSpeaker::dump_config() { ESP_LOGCONFIG(TAG, "Mixer Source Speaker\n" @@ -55,22 +90,70 @@ void SourceSpeaker::dump_config() { } void SourceSpeaker::setup() { - this->parent_->get_output_speaker()->add_audio_output_callback([this](uint32_t new_frames, int64_t write_timestamp) { - // The SourceSpeaker may not have included any audio in the mixed output, so verify there were pending frames - uint32_t speakers_playback_frames = std::min(new_frames, this->pending_playback_frames_); - this->pending_playback_frames_ -= speakers_playback_frames; + if (!create_event_group(this->event_group_, this)) { + return; + } - if (speakers_playback_frames > 0) { - this->audio_output_callback_(speakers_playback_frames, write_timestamp); + // Start with loop disabled since we begin in STATE_STOPPED with no pending commands + this->disable_loop(); + + this->parent_->get_output_speaker()->add_audio_output_callback([this](uint32_t new_frames, int64_t write_timestamp) { + // First, drain the playback delay (frames in pipeline before this source started contributing) + uint32_t delay_to_drain = atomic_subtract_clamped(this->playback_delay_frames_, new_frames); + uint32_t remaining_frames = new_frames - delay_to_drain; + + // Then, count towards this source's pending playback frames + if (remaining_frames > 0) { + uint32_t speakers_playback_frames = atomic_subtract_clamped(this->pending_playback_frames_, remaining_frames); + if (speakers_playback_frames > 0) { + this->audio_output_callback_(speakers_playback_frames, write_timestamp); + } } }); } void SourceSpeaker::loop() { + uint32_t event_bits = xEventGroupGetBits(this->event_group_); + + // Process commands with priority: STOP > FINISH > START + // This ensures stop commands take precedence over conflicting start commands + if (event_bits & SOURCE_SPEAKER_COMMAND_STOP) { + if (this->state_ == speaker::STATE_RUNNING) { + // Clear both STOP and START bits - stop takes precedence + xEventGroupClearBits(this->event_group_, SOURCE_SPEAKER_COMMAND_STOP | SOURCE_SPEAKER_COMMAND_START); + this->enter_stopping_state_(); + } else if (this->state_ == speaker::STATE_STOPPED) { + // Already stopped, just clear the command bits + xEventGroupClearBits(this->event_group_, SOURCE_SPEAKER_COMMAND_STOP | SOURCE_SPEAKER_COMMAND_START); + } + // Leave bits set if transitioning states (STARTING/STOPPING) - will be processed once state allows + } else if (event_bits & SOURCE_SPEAKER_COMMAND_FINISH) { + if (this->state_ == speaker::STATE_RUNNING) { + xEventGroupClearBits(this->event_group_, SOURCE_SPEAKER_COMMAND_FINISH); + this->stop_gracefully_ = true; + } else if (this->state_ == speaker::STATE_STOPPED) { + // Already stopped, just clear the command bit + xEventGroupClearBits(this->event_group_, SOURCE_SPEAKER_COMMAND_FINISH); + } + // Leave bit set if transitioning states - will be processed once state allows + } else if (event_bits & SOURCE_SPEAKER_COMMAND_START) { + if (this->state_ == speaker::STATE_STOPPED) { + xEventGroupClearBits(this->event_group_, SOURCE_SPEAKER_COMMAND_START); + this->state_ = speaker::STATE_STARTING; + } else if (this->state_ == speaker::STATE_RUNNING) { + // Already running, just clear the command bit + xEventGroupClearBits(this->event_group_, SOURCE_SPEAKER_COMMAND_START); + } + // Leave bit set if transitioning states - will be processed once state allows + } + // Process state machine switch (this->state_) { case speaker::STATE_STARTING: { esp_err_t err = this->start_(); if (err == ESP_OK) { + this->pending_playback_frames_.store(0, std::memory_order_release); // reset pending playback frames + this->playback_delay_frames_.store(0, std::memory_order_release); // reset playback delay + this->has_contributed_.store(false, std::memory_order_release); // reset contribution tracking this->state_ = speaker::STATE_RUNNING; this->stop_gracefully_ = false; this->last_seen_data_ms_ = millis(); @@ -78,41 +161,62 @@ void SourceSpeaker::loop() { } else { switch (err) { case ESP_ERR_NO_MEM: - this->status_set_error(LOG_STR("Failed to start mixer: not enough memory")); + this->status_set_error(LOG_STR("Not enough memory")); break; case ESP_ERR_NOT_SUPPORTED: - this->status_set_error(LOG_STR("Failed to start mixer: unsupported bits per sample")); + this->status_set_error(LOG_STR("Unsupported bit depth")); break; case ESP_ERR_INVALID_ARG: - this->status_set_error( - LOG_STR("Failed to start mixer: audio stream isn't compatible with the other audio stream.")); + this->status_set_error(LOG_STR("Incompatible audio streams")); break; case ESP_ERR_INVALID_STATE: - this->status_set_error(LOG_STR("Failed to start mixer: mixer task failed to start")); + this->status_set_error(LOG_STR("Task failed")); break; default: - this->status_set_error(LOG_STR("Failed to start mixer")); + this->status_set_error(LOG_STR("Failed")); break; } - this->state_ = speaker::STATE_STOPPING; + this->enter_stopping_state_(); } break; } case speaker::STATE_RUNNING: - if (!this->transfer_buffer_->has_buffered_data()) { + if (!this->transfer_buffer_->has_buffered_data() && + (this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) { + // No audio data in buffer waiting to get mixed and no frames are pending playback if ((this->timeout_ms_.has_value() && ((millis() - this->last_seen_data_ms_) > this->timeout_ms_.value())) || this->stop_gracefully_) { - this->state_ = speaker::STATE_STOPPING; + // Timeout exceeded or graceful stop requested + this->enter_stopping_state_(); } } break; - case speaker::STATE_STOPPING: - this->stop_(); - this->stop_gracefully_ = false; - this->state_ = speaker::STATE_STOPPED; + case speaker::STATE_STOPPING: { + if ((this->parent_->get_output_speaker()->get_pause_state()) || + ((millis() - this->stopping_start_ms_) > STOPPING_TIMEOUT_MS)) { + // If parent speaker is paused or if the stopping timeout is exceeded, force stop the output speaker + this->parent_->get_output_speaker()->stop(); + } + + if (this->parent_->get_output_speaker()->is_stopped() || + (this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) { + // Output speaker is stopped OR all pending playback frames have played + this->pending_playback_frames_.store(0, std::memory_order_release); + this->stop_gracefully_ = false; + + this->state_ = speaker::STATE_STOPPED; + } break; + } case speaker::STATE_STOPPED: + // Re-check event bits for any new commands that may have arrived + event_bits = xEventGroupGetBits(this->event_group_); + if (!(event_bits & + (SOURCE_SPEAKER_COMMAND_START | SOURCE_SPEAKER_COMMAND_STOP | SOURCE_SPEAKER_COMMAND_FINISH))) { + // No pending commands, disable loop to save CPU cycles + this->disable_loop(); + } break; } } @@ -122,17 +226,34 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ this->start(); } size_t bytes_written = 0; - if (this->ring_buffer_.use_count() == 1) { - std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + if (temp_ring_buffer.use_count() > 0) { + // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { this->last_seen_data_ms_ = millis(); } + } else { + // Delay to avoid repeatedly hammering while waiting for the speaker to start + vTaskDelay(ticks_to_wait); } return bytes_written; } -void SourceSpeaker::start() { this->state_ = speaker::STATE_STARTING; } +void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { + this->enable_loop_soon_any_context(); + uint32_t event_bits = xEventGroupGetBits(this->event_group_); + if (!(event_bits & command_bit)) { + xEventGroupSetBits(this->event_group_, command_bit); +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + if (wake_loop) { + App.wake_loop_threadsafe(); + } +#endif + } +} + +void SourceSpeaker::start() { this->send_command_(SOURCE_SPEAKER_COMMAND_START, true); } esp_err_t SourceSpeaker::start_() { const size_t ring_buffer_size = this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_); @@ -143,35 +264,26 @@ esp_err_t SourceSpeaker::start_() { if (this->transfer_buffer_ == nullptr) { return ESP_ERR_NO_MEM; } - std::shared_ptr temp_ring_buffer; - if (!this->ring_buffer_.use_count()) { + std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); + if (!temp_ring_buffer) { temp_ring_buffer = RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!this->ring_buffer_.use_count()) { + if (!temp_ring_buffer) { return ESP_ERR_NO_MEM; } else { this->transfer_buffer_->set_source(temp_ring_buffer); } } - this->pending_playback_frames_ = 0; // reset return this->parent_->start(this->audio_stream_info_); } -void SourceSpeaker::stop() { - if (this->state_ != speaker::STATE_STOPPED) { - this->state_ = speaker::STATE_STOPPING; - } -} +void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } -void SourceSpeaker::stop_() { - this->transfer_buffer_.reset(); // deallocates the transfer buffer -} - -void SourceSpeaker::finish() { this->stop_gracefully_ = true; } +void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { return ((this->transfer_buffer_.use_count() > 0) && this->transfer_buffer_->has_buffered_data()); @@ -191,19 +303,16 @@ void SourceSpeaker::set_volume(float volume) { float SourceSpeaker::get_volume() { return this->parent_->get_output_speaker()->get_volume(); } -size_t SourceSpeaker::process_data_from_source(TickType_t ticks_to_wait) { - if (!this->transfer_buffer_.use_count()) { - return 0; - } - +size_t SourceSpeaker::process_data_from_source(std::shared_ptr &transfer_buffer, + TickType_t ticks_to_wait) { // Store current offset, as these samples are already ducked - const size_t current_length = this->transfer_buffer_->available(); + const size_t current_length = transfer_buffer->available(); - size_t bytes_read = this->transfer_buffer_->transfer_data_from_source(ticks_to_wait); + size_t bytes_read = transfer_buffer->transfer_data_from_source(ticks_to_wait); uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - int16_t *current_buffer = reinterpret_cast(this->transfer_buffer_->get_buffer_start() + current_length); + int16_t *current_buffer = reinterpret_cast(transfer_buffer->get_buffer_start() + current_length); duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_, &this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_, @@ -215,10 +324,13 @@ size_t SourceSpeaker::process_data_from_source(TickType_t ticks_to_wait) { void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) { if (this->target_ducking_db_reduction_ != decibel_reduction) { + // Start transition from the previous target (which becomes the new current level) this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; this->target_ducking_db_reduction_ = decibel_reduction; + // Calculate the number of intermediate dB steps for the transition timing. + // Subtract 1 because the first step is taken immediately after this calculation. uint8_t total_ducking_steps = 0; if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) { // The dB reduction level is increasing (which results in quieter audio) @@ -234,7 +346,7 @@ void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps; this->ducking_transition_samples_remaining_ = - this->samples_per_ducking_step_ * total_ducking_steps; // Adjust for integer division rounding + this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_; } else { @@ -293,6 +405,12 @@ void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_t } } +void SourceSpeaker::enter_stopping_state_() { + this->state_ = speaker::STATE_STOPPING; + this->stopping_start_ms_ = millis(); + this->transfer_buffer_.reset(); +} + void MixerSpeaker::dump_config() { ESP_LOGCONFIG(TAG, "Speaker Mixer:\n" @@ -301,42 +419,74 @@ void MixerSpeaker::dump_config() { } void MixerSpeaker::setup() { - this->event_group_ = xEventGroupCreate(); - - if (this->event_group_ == nullptr) { - ESP_LOGE(TAG, "Failed to create event group"); - this->mark_failed(); + if (!create_event_group(this->event_group_, this)) { return; } + + // Register callback to track frames in the output pipeline + this->output_speaker_->add_audio_output_callback([this](uint32_t new_frames, int64_t write_timestamp) { + atomic_subtract_clamped(this->frames_in_pipeline_, new_frames); + }); + + // Start with loop disabled since no task is running and no commands are pending + this->disable_loop(); } void MixerSpeaker::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); - if (event_group_bits & MixerEventGroupBits::STATE_STARTING) { - ESP_LOGD(TAG, "Starting speaker mixer"); - xEventGroupClearBits(this->event_group_, MixerEventGroupBits::STATE_STARTING); + // Handle pending start request + if (event_group_bits & MIXER_TASK_COMMAND_START) { + // Only start the task if it's fully stopped and cleaned up + if (!this->status_has_error() && (this->task_handle_ == nullptr) && (this->task_stack_buffer_ == nullptr)) { + esp_err_t err = this->start_task_(); + switch (err) { + case ESP_OK: + xEventGroupClearBits(this->event_group_, MIXER_TASK_COMMAND_START); + break; + case ESP_ERR_NO_MEM: + ESP_LOGE(TAG, "Failed to start; retrying in 1 second"); + this->status_momentary_error("memory-failure", 1000); + return; + case ESP_ERR_INVALID_STATE: + ESP_LOGE(TAG, "Failed to start; retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + return; + default: + ESP_LOGE(TAG, "Failed to start; retrying in 1 second"); + this->status_momentary_error("failure", 1000); + return; + } + } } - if (event_group_bits & MixerEventGroupBits::ERR_ESP_NO_MEM) { - this->status_set_error(LOG_STR("Failed to allocate the mixer's internal buffer")); - xEventGroupClearBits(this->event_group_, MixerEventGroupBits::ERR_ESP_NO_MEM); + + if (event_group_bits & MIXER_TASK_STATE_STARTING) { + ESP_LOGD(TAG, "Starting"); + xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STARTING); } - if (event_group_bits & MixerEventGroupBits::STATE_RUNNING) { - ESP_LOGD(TAG, "Started speaker mixer"); + if (event_group_bits & MIXER_TASK_ERR_ESP_NO_MEM) { + this->status_set_error(LOG_STR("Not enough memory")); + xEventGroupClearBits(this->event_group_, MIXER_TASK_ERR_ESP_NO_MEM); + } + if (event_group_bits & MIXER_TASK_STATE_RUNNING) { + ESP_LOGV(TAG, "Started"); this->status_clear_error(); - xEventGroupClearBits(this->event_group_, MixerEventGroupBits::STATE_RUNNING); + xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_RUNNING); } - if (event_group_bits & MixerEventGroupBits::STATE_STOPPING) { - ESP_LOGD(TAG, "Stopping speaker mixer"); - xEventGroupClearBits(this->event_group_, MixerEventGroupBits::STATE_STOPPING); + if (event_group_bits & MIXER_TASK_STATE_STOPPING) { + ESP_LOGV(TAG, "Stopping"); + xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MixerEventGroupBits::STATE_STOPPED) { + if (event_group_bits & MIXER_TASK_STATE_STOPPED) { if (this->delete_task_() == ESP_OK) { - xEventGroupClearBits(this->event_group_, MixerEventGroupBits::ALL_BITS); + ESP_LOGD(TAG, "Stopped"); + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); } } if (this->task_handle_ != nullptr) { + // If the mixer task is running, check if all source speakers are stopped + bool all_stopped = true; for (auto &speaker : this->source_speakers_) { @@ -344,7 +494,15 @@ void MixerSpeaker::loop() { } if (all_stopped) { - this->stop(); + // Send stop command signal to the mixer task since no source speakers are active + xEventGroupSetBits(this->event_group_, MIXER_TASK_COMMAND_STOP); + } + } else if (this->task_stack_buffer_ == nullptr) { + // Task is fully stopped and cleaned up, check if we can disable loop + event_group_bits = xEventGroupGetBits(this->event_group_); + if (event_group_bits == 0) { + // No pending events, disable loop to save CPU cycles + this->disable_loop(); } } } @@ -366,7 +524,18 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { } } - return this->start_task_(); + this->enable_loop_soon_any_context(); // ensure loop processes command + + uint32_t event_bits = xEventGroupGetBits(this->event_group_); + if (!(event_bits & MIXER_TASK_COMMAND_START)) { + // Set MIXER_TASK_COMMAND_START bit if not already set, and then immediately wake for low latency + xEventGroupSetBits(this->event_group_, MIXER_TASK_COMMAND_START); +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } + + return ESP_OK; } esp_err_t MixerSpeaker::start_task_() { @@ -397,28 +566,31 @@ esp_err_t MixerSpeaker::start_task_() { } esp_err_t MixerSpeaker::delete_task_() { - if (!this->task_created_) { + if (this->task_handle_ != nullptr) { + // Delete the task + vTaskDelete(this->task_handle_); this->task_handle_ = nullptr; - - if (this->task_stack_buffer_ != nullptr) { - if (this->task_stack_in_psram_) { - RAMAllocator stack_allocator(RAMAllocator::ALLOC_EXTERNAL); - stack_allocator.deallocate(this->task_stack_buffer_, TASK_STACK_SIZE); - } else { - RAMAllocator stack_allocator(RAMAllocator::ALLOC_INTERNAL); - stack_allocator.deallocate(this->task_stack_buffer_, TASK_STACK_SIZE); - } - - this->task_stack_buffer_ = nullptr; - } - - return ESP_OK; } - return ESP_ERR_INVALID_STATE; -} + if ((this->task_handle_ == nullptr) && (this->task_stack_buffer_ != nullptr)) { + // Deallocate the task stack buffer + if (this->task_stack_in_psram_) { + RAMAllocator stack_allocator(RAMAllocator::ALLOC_EXTERNAL); + stack_allocator.deallocate(this->task_stack_buffer_, TASK_STACK_SIZE); + } else { + RAMAllocator stack_allocator(RAMAllocator::ALLOC_INTERNAL); + stack_allocator.deallocate(this->task_stack_buffer_, TASK_STACK_SIZE); + } -void MixerSpeaker::stop() { xEventGroupSetBits(this->event_group_, MixerEventGroupBits::COMMAND_STOP); } + this->task_stack_buffer_ = nullptr; + } + + if ((this->task_handle_ != nullptr) || (this->task_stack_buffer_ != nullptr)) { + return ESP_ERR_INVALID_STATE; + } + + return ESP_OK; +} void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, @@ -472,32 +644,34 @@ void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::Audio } void MixerSpeaker::audio_mixer_task(void *params) { - MixerSpeaker *this_mixer = (MixerSpeaker *) params; + MixerSpeaker *this_mixer = static_cast(params); - xEventGroupSetBits(this_mixer->event_group_, MixerEventGroupBits::STATE_STARTING); - - this_mixer->task_created_ = true; + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STARTING); std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); if (output_transfer_buffer == nullptr) { - xEventGroupSetBits(this_mixer->event_group_, - MixerEventGroupBits::STATE_STOPPED | MixerEventGroupBits::ERR_ESP_NO_MEM); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); - this_mixer->task_created_ = false; - vTaskDelete(nullptr); + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } output_transfer_buffer->set_sink(this_mixer->output_speaker_); - xEventGroupSetBits(this_mixer->event_group_, MixerEventGroupBits::STATE_RUNNING); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); bool sent_finished = false; + // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) + FixedVector speakers_with_data; + FixedVector> transfer_buffers_with_data; + speakers_with_data.init(this_mixer->source_speakers_.size()); + transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); + while (true) { uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); - if (event_group_bits & MixerEventGroupBits::COMMAND_STOP) { + if (event_group_bits & MIXER_TASK_COMMAND_STOP) { break; } @@ -507,15 +681,20 @@ void MixerSpeaker::audio_mixer_task(void *params) { const uint32_t output_frames_free = this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); - std::vector speakers_with_data; - std::vector> transfer_buffers_with_data; + speakers_with_data.clear(); + transfer_buffers_with_data.clear(); for (auto &speaker : this_mixer->source_speakers_) { - if (speaker->get_transfer_buffer().use_count() > 0) { + if (speaker->is_running() && !speaker->get_pause_state()) { + // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); - speaker->process_data_from_source(0); // Transfers and ducks audio from source ring buffers + if (transfer_buffer.use_count() == 0) { + // No transfer buffer allocated, so skip processing this speaker + continue; + } + speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers - if ((transfer_buffer->available() > 0) && !speaker->get_pause_state()) { + if (transfer_buffer->available() > 0) { // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop transfer_buffers_with_data.push_back(transfer_buffer); speakers_with_data.push_back(speaker); @@ -547,13 +726,21 @@ void MixerSpeaker::audio_mixer_task(void *params) { reinterpret_cast(output_transfer_buffer->get_buffer_end()), this_mixer->audio_stream_info_.value(), frames_to_mix); - // Update source speaker buffer length - transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); - speakers_with_data[0]->pending_playback_frames_ += frames_to_mix; + // Set playback delay for newly contributing source + if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[0]->playback_delay_frames_.store( + this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); + speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + } - // Update output transfer buffer length + // Update source speaker pending frames + speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + + // Update output transfer buffer length and pipeline frame count output_transfer_buffer->increase_buffer_length( this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } else { // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker if (!this_mixer->output_speaker_->is_stopped()) { @@ -568,6 +755,8 @@ void MixerSpeaker::audio_mixer_task(void *params) { active_stream_info.get_sample_rate()); this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); this_mixer->output_speaker_->start(); + // Reset pipeline frame count since we're starting fresh with a new sample rate + this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); sent_finished = false; } } @@ -596,26 +785,39 @@ void MixerSpeaker::audio_mixer_task(void *params) { } } + // Get current pipeline depth for delay calculation (before incrementing) + uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); + // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + // Set playback delay for newly contributing sources + if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); + speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); + } + + speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); transfer_buffers_with_data[i]->decrease_buffer_length( speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); - speakers_with_data[i]->pending_playback_frames_ += frames_to_mix; } - // Update output transfer buffer length + // Update output transfer buffer length and pipeline frame count (once, not per source) output_transfer_buffer->increase_buffer_length( this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } } - xEventGroupSetBits(this_mixer->event_group_, MixerEventGroupBits::STATE_STOPPING); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + + // Reset pipeline frame count since the task is stopping + this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); output_transfer_buffer.reset(); - xEventGroupSetBits(this_mixer->event_group_, MixerEventGroupBits::STATE_STOPPED); - this_mixer->task_created_ = false; - vTaskDelete(nullptr); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED); + + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } } // namespace mixer_speaker diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 48bacc4471..e920f9895a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -7,26 +7,31 @@ #include "esphome/components/speaker/speaker.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" -#include #include +#include + +#include namespace esphome { namespace mixer_speaker { /* Classes for mixing several source speaker audio streams and writing it to another speaker component. * - Volume controls are passed through to the output speaker + * - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread + * safety * - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker. - * - Audio sent to the SourceSpeaker's must have 16 bits per sample. + * - Audio sent to the SourceSpeaker must have 16 bits per sample. * - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match * the number of channels required for the output speaker. - * - In queue mode, the audio sent to the SoureSpeakers can have different sample rates. + * - In queue mode, the audio sent to the SourceSpeakers can have different sample rates. * - In non-queue mode, the audio sent to the SourceSpeakers must have the same sample rates. * - SourceSpeaker has an internal ring buffer. It also allocates a shared_ptr for an AudioTranserBuffer object. * - Audio Data Flow: * - Audio data played on a SourceSpeaker first writes to its internal ring buffer. * - MixerSpeaker task temporarily takes shared ownership of each SourceSpeaker's AudioTransferBuffer. - * - MixerSpeaker calls SourceSpeaker's `process_data_from_source`, which tranfers audio from the SourceSpeaker's + * - MixerSpeaker calls SourceSpeaker's `process_data_from_source`, which transfers audio from the SourceSpeaker's * ring buffer to its AudioTransferBuffer. Audio ducking is applied at this step. * - In queue mode, MixerSpeaker prioritizes the earliest configured SourceSpeaker with audio data. Audio data is * sent to the output speaker. @@ -63,13 +68,15 @@ class SourceSpeaker : public speaker::Speaker, public Component { bool get_pause_state() const override { return this->pause_state_; } /// @brief Transfers audio from the ring buffer into the transfer buffer. Ducks audio while transferring. + /// @param transfer_buffer Locked shared_ptr to the transfer buffer (must be valid, not null) /// @param ticks_to_wait FreeRTOS ticks to wait while waiting to read from the ring buffer. /// @return Number of bytes transferred from the ring buffer. - size_t process_data_from_source(TickType_t ticks_to_wait); + size_t process_data_from_source(std::shared_ptr &transfer_buffer, + TickType_t ticks_to_wait); /// @brief Sets the ducking level for the source speaker. - /// @param decibel_reduction (uint8_t) The dB reduction level. For example, 0 is no change, 10 is a reduction by 10 dB - /// @param duration (uint32_t) The number of milliseconds to transition from the current level to the new level + /// @param decibel_reduction The dB reduction level. For example, 0 is no change, 10 is a reduction by 10 dB + /// @param duration The number of milliseconds to transition from the current level to the new level void apply_ducking(uint8_t decibel_reduction, uint32_t duration); void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } @@ -81,14 +88,15 @@ class SourceSpeaker : public speaker::Speaker, public Component { protected: friend class MixerSpeaker; esp_err_t start_(); - void stop_(); + void enter_stopping_state_(); + void send_command_(uint32_t command_bit, bool wake_loop = false); /// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually /// over a specified amount of samples. /// @param input_buffer buffer with audio samples to be ducked in place /// @param input_samples_to_duck number of samples to process in ``input_buffer`` /// @param current_ducking_db_reduction pointer to the current dB reduction - /// @param ducking_transition_samples_remaining pointer to the total number of samples left before the the + /// @param ducking_transition_samples_remaining pointer to the total number of samples left before the /// transition is finished /// @param samples_per_ducking_step total number of samples per ducking step for the transition /// @param db_change_per_ducking_step the change in dB reduction per step @@ -114,7 +122,12 @@ class SourceSpeaker : public speaker::Speaker, public Component { uint32_t ducking_transition_samples_remaining_{0}; uint32_t samples_per_ducking_step_{0}; - uint32_t pending_playback_frames_{0}; + std::atomic pending_playback_frames_{0}; + std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing + std::atomic has_contributed_{false}; // Tracks if source has contributed during this session + + EventGroupHandle_t event_group_{nullptr}; + uint32_t stopping_start_ms_{0}; }; class MixerSpeaker : public Component { @@ -123,10 +136,11 @@ class MixerSpeaker : public Component { void setup() override; void loop() override; + void init_source_speakers(size_t count) { this->source_speakers_.init(count); } void add_source_speaker(SourceSpeaker *source_speaker) { this->source_speakers_.push_back(source_speaker); } /// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information - /// @param stream_info The calling source speakers audio stream information + /// @param stream_info The calling source speaker's audio stream information /// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample /// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream /// ESP_ERR_NO_MEM if there isn't enough memory for the task's stack @@ -134,8 +148,6 @@ class MixerSpeaker : public Component { /// ESP_OK if the incoming stream is compatible and the mixer task starts esp_err_t start(audio::AudioStreamInfo &stream_info); - void stop(); - void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; } void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; } void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; } @@ -143,6 +155,9 @@ class MixerSpeaker : public Component { speaker::Speaker *get_output_speaker() const { return this->output_speaker_; } + /// @brief Returns the current number of frames in the output pipeline (written but not yet played) + uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); } + protected: /// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels /// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has @@ -159,11 +174,11 @@ class MixerSpeaker : public Component { /// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number /// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample /// overflows. - /// @param primary_buffer (int16_t *) samples buffer for the primary stream + /// @param primary_buffer samples buffer for the primary stream /// @param primary_stream_info stream info for the primary stream - /// @param secondary_buffer (int16_t *) samples buffer for secondary stream + /// @param secondary_buffer samples buffer for secondary stream /// @param secondary_stream_info stream info for the secondary stream - /// @param output_buffer (int16_t *) buffer for the mixed samples + /// @param output_buffer buffer for the mixed samples /// @param output_stream_info stream info for the output buffer /// @param frames_to_mix number of frames in the primary and secondary buffers to mix together static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, @@ -185,20 +200,20 @@ class MixerSpeaker : public Component { EventGroupHandle_t event_group_{nullptr}; - std::vector source_speakers_; + FixedVector source_speakers_; speaker::Speaker *output_speaker_{nullptr}; uint8_t output_channels_; bool queue_mode_; bool task_stack_in_psram_{false}; - bool task_created_{false}; - TaskHandle_t task_handle_{nullptr}; StaticTask_t task_stack_; StackType_t *task_stack_buffer_{nullptr}; optional audio_stream_info_; + + std::atomic frames_in_pipeline_{0}; // Frames written to output but not yet played }; } // namespace mixer_speaker From 919afa1553a40bd9c756d871a87b55a77de7bfb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 11:47:59 -0600 Subject: [PATCH 13/37] [web_server_base] Fix RP2040 compilation when Crypto-no-arduino is present (#13887) --- esphome/components/web_server_base/__init__.py | 13 +++++++++++++ .../web_server_base/fix_rp2040_hash.py.script | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 esphome/components/web_server_base/fix_rp2040_hash.py.script diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 11408ae260..7986ac964d 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,11 @@ +from pathlib import Path + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.helpers import copy_file_if_changed CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -49,5 +52,15 @@ async def to_code(config): CORE.add_platformio_option( "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] ) + # ESPAsyncWebServer uses Hash library for sha1() on RP2040 + cg.add_library("Hash", None) + # Fix Hash.h include conflict: Crypto-no-arduino (used by dsmr) + # provides a Hash.h that shadows the framework's Hash library. + # Prepend the framework Hash path so it's found first. + copy_file_if_changed( + Path(__file__).parent / "fix_rp2040_hash.py.script", + CORE.relative_build_path("fix_rp2040_hash.py"), + ) + cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") diff --git a/esphome/components/web_server_base/fix_rp2040_hash.py.script b/esphome/components/web_server_base/fix_rp2040_hash.py.script new file mode 100644 index 0000000000..2cf24569de --- /dev/null +++ b/esphome/components/web_server_base/fix_rp2040_hash.py.script @@ -0,0 +1,11 @@ +# ESPAsyncWebServer includes expecting the Arduino-Pico framework's Hash +# library (which provides sha1() functions). However, the Crypto-no-arduino library +# (used by dsmr) also provides a Hash.h that can shadow the framework version when +# PlatformIO's chain+ LDF mode auto-discovers it as a dependency. +# Prepend the framework Hash path to CXXFLAGS so it is found first. +import os + +Import("env") +framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico") +hash_src = os.path.join(framework_dir, "libraries", "Hash", "src") +env.Prepend(CXXFLAGS=["-I" + hash_src]) From 04a6238c7b2decb53e89066ce0da49d6e56b34ff Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:49:58 -0500 Subject: [PATCH 14/37] [esp32] Set UV_CACHE_DIR inside data dir so Clean All clears it (#13888) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 90f6035aba..6f5011246c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1435,6 +1435,10 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + # Set the uv cache inside the data dir so "Clean All" clears it. + # Avoids persistent corrupted cache from mid-stream download failures. + os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache")) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") From c658d7b57faa41def0808c11d6fbd046abd4eb6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:02:02 -0600 Subject: [PATCH 15/37] [api] Merge auth check into base read_message, eliminate APIServerConnection (#13873) --- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2_service.cpp | 41 +++--- esphome/components/api/api_pb2_service.h | 5 - script/api_protobuf/api_protobuf.py | 156 ++++++++++----------- 4 files changed, 91 insertions(+), 113 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index ae7f864568..7f738a9bfd 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -28,7 +28,7 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); -class APIConnection final : public APIServerConnection { +class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1c04eacc82..2d15deb90d 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -21,6 +21,23 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name) { #endif void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { + // Check authentication/connection requirements + switch (msg_type) { + case HelloRequest::MESSAGE_TYPE: // No setup required + case DisconnectRequest::MESSAGE_TYPE: // No setup required + case PingRequest::MESSAGE_TYPE: // No setup required + break; + case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only + if (!this->check_connection_setup_()) { + return; + } + break; + default: + if (!this->check_authenticated_()) { + return; + } + break; + } switch (msg_type) { case HelloRequest::MESSAGE_TYPE: { HelloRequest msg; @@ -623,28 +640,4 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } } -void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { - // Check authentication/connection requirements for messages - switch (msg_type) { - case HelloRequest::MESSAGE_TYPE: // No setup required - case DisconnectRequest::MESSAGE_TYPE: // No setup required - case PingRequest::MESSAGE_TYPE: // No setup required - break; // Skip all checks for these messages - case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only - if (!this->check_connection_setup_()) { - return; // Connection not setup - } - break; - default: - // All other messages require authentication (which includes connection check) - if (!this->check_authenticated_()) { - return; // Authentication failed - } - break; - } - - // Call base implementation to process the message - APIServerConnectionBase::read_message(msg_size, msg_type, msg_data); -} - } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 4dc6ce27d0..1441507406 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -228,9 +228,4 @@ class APIServerConnectionBase : public ProtoService { void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; -class APIServerConnection : public APIServerConnectionBase { - protected: - void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; -}; - } // namespace esphome::api diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 8673996a25..ece0b5692f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2881,9 +2881,82 @@ static const char *const TAG = "api.service"; cases = list(RECEIVE_CASES.items()) cases.sort() + + serv = file.service[0] + + # Build a mapping of message input types to their authentication requirements + message_auth_map: dict[str, bool] = {} + message_conn_map: dict[str, bool] = {} + + for m in serv.method: + inp = m.input_type[1:] + needs_conn = get_opt(m, pb.needs_setup_connection, True) + needs_auth = get_opt(m, pb.needs_authentication, True) + + # Store authentication requirements for message types + message_auth_map[inp] = needs_auth + message_conn_map[inp] = needs_conn + + # Categorize messages by their authentication requirements + no_conn_ids: set[int] = set() + conn_only_ids: set[int] = set() + + for id_, (_, _, case_msg_name) in cases: + if case_msg_name in message_auth_map: + needs_auth = message_auth_map[case_msg_name] + needs_conn = message_conn_map[case_msg_name] + + if not needs_conn: + no_conn_ids.add(id_) + elif not needs_auth: + conn_only_ids.add(id_) + + # Helper to generate case statements with ifdefs + def generate_cases(ids: set[int], comment: str) -> str: + result = "" + for id_ in sorted(ids): + _, ifdef, msg_name = RECEIVE_CASES[id_] + if ifdef: + result += f"#ifdef {ifdef}\n" + result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" + if ifdef: + result += "#endif\n" + return result + + # Generate read_message with auth check before dispatch hpp += " protected:\n" hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" + out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" + + # Auth check block before dispatch switch + out += " // Check authentication/connection requirements\n" + if no_conn_ids or conn_only_ids: + out += " switch (msg_type) {\n" + + if no_conn_ids: + out += generate_cases(no_conn_ids, "// No setup required") + out += " break;\n" + + if conn_only_ids: + out += generate_cases(conn_only_ids, "// Connection setup only") + out += " if (!this->check_connection_setup_()) {\n" + out += " return;\n" + out += " }\n" + out += " break;\n" + + out += " default:\n" + out += " if (!this->check_authenticated_()) {\n" + out += " return;\n" + out += " }\n" + out += " break;\n" + out += " }\n" + else: + out += " if (!this->check_authenticated_()) {\n" + out += " return;\n" + out += " }\n" + + # Dispatch switch out += " switch (msg_type) {\n" for i, (case, ifdef, message_name) in cases: if ifdef is not None: @@ -2902,89 +2975,6 @@ static const char *const TAG = "api.service"; cpp += out hpp += "};\n" - serv = file.service[0] - class_name = "APIServerConnection" - hpp += "\n" - hpp += f"class {class_name} : public {class_name}Base {{\n" - hpp_protected = "" - cpp += "\n" - - # Build a mapping of message input types to their authentication requirements - message_auth_map: dict[str, bool] = {} - message_conn_map: dict[str, bool] = {} - - for m in serv.method: - inp = m.input_type[1:] - needs_conn = get_opt(m, pb.needs_setup_connection, True) - needs_auth = get_opt(m, pb.needs_authentication, True) - - # Store authentication requirements for message types - message_auth_map[inp] = needs_auth - message_conn_map[inp] = needs_conn - - # Generate optimized read_message with authentication checking - # Categorize messages by their authentication requirements - no_conn_ids: set[int] = set() - conn_only_ids: set[int] = set() - - for id_, (_, _, case_msg_name) in cases: - if case_msg_name in message_auth_map: - needs_auth = message_auth_map[case_msg_name] - needs_conn = message_conn_map[case_msg_name] - - if not needs_conn: - no_conn_ids.add(id_) - elif not needs_auth: - conn_only_ids.add(id_) - - # Generate override if we have messages that skip checks - if no_conn_ids or conn_only_ids: - # Helper to generate case statements with ifdefs - def generate_cases(ids: set[int], comment: str) -> str: - result = "" - for id_ in sorted(ids): - _, ifdef, msg_name = RECEIVE_CASES[id_] - if ifdef: - result += f"#ifdef {ifdef}\n" - result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" - if ifdef: - result += "#endif\n" - return result - - hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" - - cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" - cpp += " // Check authentication/connection requirements for messages\n" - cpp += " switch (msg_type) {\n" - - # Messages that don't need any checks - if no_conn_ids: - cpp += generate_cases(no_conn_ids, "// No setup required") - cpp += " break; // Skip all checks for these messages\n" - - # Messages that only need connection setup - if conn_only_ids: - cpp += generate_cases(conn_only_ids, "// Connection setup only") - cpp += " if (!this->check_connection_setup_()) {\n" - cpp += " return; // Connection not setup\n" - cpp += " }\n" - cpp += " break;\n" - - cpp += " default:\n" - cpp += " // All other messages require authentication (which includes connection check)\n" - cpp += " if (!this->check_authenticated_()) {\n" - cpp += " return; // Authentication failed\n" - cpp += " }\n" - cpp += " break;\n" - cpp += " }\n\n" - cpp += " // Call base implementation to process the message\n" - cpp += f" {class_name}Base::read_message(msg_size, msg_type, msg_data);\n" - cpp += "}\n" - - hpp += " protected:\n" - hpp += hpp_protected - hpp += "};\n" - hpp += """\ } // namespace esphome::api From 2383b6b8b461d14594d9cd08199d7bc3db444a8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:05:32 -0600 Subject: [PATCH 16/37] [core] Deprecate set_retry, cancel_retry, and RetryResult (#13845) --- esphome/core/component.cpp | 19 +++++++++- esphome/core/component.h | 71 ++++++++++++-------------------------- esphome/core/scheduler.cpp | 7 ++++ esphome/core/scheduler.h | 23 +++++++++--- 4 files changed, 66 insertions(+), 54 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index f09a39d2bb..6d8d1c57af 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -152,7 +152,10 @@ void Component::set_retry(const std::string &name, uint32_t initial_wait_time, u void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +#pragma GCC diagnostic pop } bool Component::cancel_retry(const std::string &name) { // NOLINT @@ -163,7 +166,10 @@ bool Component::cancel_retry(const std::string &name) { // NOLINT } bool Component::cancel_retry(const char *name) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" return App.scheduler.cancel_retry(this, name); +#pragma GCC diagnostic pop } void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT @@ -203,10 +209,18 @@ bool Component::cancel_interval(uint32_t id) { return App.scheduler.cancel_inter void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +#pragma GCC diagnostic pop } -bool Component::cancel_retry(uint32_t id) { return App.scheduler.cancel_retry(this, id); } +bool Component::cancel_retry(uint32_t id) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return App.scheduler.cancel_retry(this, id); +#pragma GCC diagnostic pop +} void Component::call_loop() { this->loop(); } void Component::call_setup() { this->setup(); } @@ -371,7 +385,10 @@ void Component::set_interval(uint32_t interval, std::function &&f) { // } void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +#pragma GCC diagnostic pop } bool Component::is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; } bool Component::is_ready() const { diff --git a/esphome/core/component.h b/esphome/core/component.h index 97f2afe1a4..c3582e23b1 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -68,6 +68,7 @@ extern const uint8_t STATUS_LED_OK; extern const uint8_t STATUS_LED_WARNING; extern const uint8_t STATUS_LED_ERROR; +// Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; extern const uint16_t WARN_IF_BLOCKING_OVER_MS; @@ -347,68 +348,40 @@ class Component { bool cancel_interval(const char *name); // NOLINT bool cancel_interval(uint32_t id); // NOLINT - /** Set an retry function with a unique name. Empty name means no cancelling possible. - * - * This will call the retry function f on the next scheduler loop. f should return RetryResult::DONE if - * it is successful and no repeat is required. Otherwise, returning RetryResult::RETRY will call f - * again in the future. - * - * The first retry of f happens after `initial_wait_time` milliseconds. The delay between retries is - * increased by multiplying by `backoff_increase_factor` each time. If no backoff_increase_factor is - * supplied (default = 1.0), the wait time will stay constant. - * - * The retry function f needs to accept a single argument: the number of attempts remaining. On the - * final retry of f, this value will be 0. - * - * This retry function can also be cancelled by name via cancel_retry(). - * - * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delayed. - * - * REMARK: It is an error to supply a negative or zero `backoff_increase_factor`, and 1.0 will be used instead. - * - * REMARK: The interval between retries is stored into a `uint32_t`, so this doesn't behave correctly - * if `initial_wait_time * (backoff_increase_factor ** (max_attempts - 2))` overflows. - * - * @param name The identifier for this retry function. - * @param initial_wait_time The time in ms before f is called again - * @param max_attempts The maximum number of executions - * @param f The function (or lambda) that should be called - * @param backoff_increase_factor time between retries is multiplied by this factor on every retry after the first - * @see cancel_retry() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - /** Set a retry function with a numeric ID (zero heap allocation). - * - * @param id The numeric identifier for this retry function - * @param initial_wait_time The wait time after the first execution - * @param max_attempts The max number of attempts - * @param f The function to call - * @param backoff_increase_factor The factor to increase the retry interval by - */ + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT float backoff_increase_factor = 1.0f); // NOLINT - /** Cancel a retry function. - * - * @param name The identifier for this retry function. - * @return Whether a retry function was deleted. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(const std::string &name); // NOLINT - bool cancel_retry(const char *name); // NOLINT - bool cancel_retry(uint32_t id); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") + bool cancel_retry(const char *name); // NOLINT + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") + bool cancel_retry(uint32_t id); // NOLINT /** Set a timeout function with a unique name. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 6797640f54..a5e308829a 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -252,6 +252,11 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL); } +// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. +// Remove before 2026.8.0 along with all retry code. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + struct RetryArgs { // Ordered to minimize padding on 32-bit systems std::function func; @@ -364,6 +369,8 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id); } +#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings + optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). // It performs cleanup and accesses items_[0] without holding a lock, which is only diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7de1023e6d..20b069f3f0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -72,18 +72,30 @@ class Scheduler { bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); - /// Set a retry with a numeric ID (zero heap allocation) + // Remove before 2026.8.0 + ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", + "2026.2.0") void set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(Component *component, const std::string &name); + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(Component *component, const char *name); + // Remove before 2026.8.0 + ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(Component *component, uint32_t id); // Calculate when the next scheduled item should run @@ -231,11 +243,14 @@ class Scheduler { uint32_t hash_or_id, uint32_t delay, std::function func, bool is_retry = false, bool skip_cancel = false); - // Common implementation for retry + // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" void set_retry_common_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor); +#pragma GCC diagnostic pop // Common implementation for cancel_retry bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); From 3b0df145b72f8df89b690ab8b3aaa7175d4d8089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:05:59 -0600 Subject: [PATCH 17/37] [cse7766] Batch UART reads to reduce loop overhead (#13817) --- esphome/components/cse7766/cse7766.cpp | 48 +++++++++++++++++--------- esphome/components/cse7766/cse7766.h | 4 ++- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index df4872deac..45abd3ca3d 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -7,7 +7,6 @@ namespace esphome { namespace cse7766 { static const char *const TAG = "cse7766"; -static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); @@ -16,25 +15,39 @@ void CSE7766Component::loop() { this->raw_data_index_ = 0; } - if (this->available() == 0) { + // Early return prevents updating last_transmission_ when no data is available. + int avail = this->available(); + if (avail <= 0) { return; } this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->raw_data_[this->raw_data_index_]); - if (!this->check_byte_()) { - this->raw_data_index_ = 0; - this->status_set_warning(); - continue; - } - if (this->raw_data_index_ == 23) { - this->parse_data_(); - this->status_clear_warning(); + // Read all available bytes in batches to reduce UART call overhead. + // At 4800 baud (~480 bytes/sec) with ~122 Hz loop rate, typically ~4 bytes per call. + uint8_t buf[CSE7766_RAW_DATA_SIZE]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; } + avail -= to_read; - this->raw_data_index_ = (this->raw_data_index_ + 1) % 24; + for (size_t i = 0; i < to_read; i++) { + this->raw_data_[this->raw_data_index_] = buf[i]; + if (!this->check_byte_()) { + this->raw_data_index_ = 0; + this->status_set_warning(); + continue; + } + + if (this->raw_data_index_ == CSE7766_RAW_DATA_SIZE - 1) { + this->parse_data_(); + this->status_clear_warning(); + } + + this->raw_data_index_ = (this->raw_data_index_ + 1) % CSE7766_RAW_DATA_SIZE; + } } } @@ -53,14 +66,15 @@ bool CSE7766Component::check_byte_() { return true; } - if (index == 23) { + if (index == CSE7766_RAW_DATA_SIZE - 1) { uint8_t checksum = 0; - for (uint8_t i = 2; i < 23; i++) { + for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { checksum += this->raw_data_[i]; } - if (checksum != this->raw_data_[23]) { - ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, this->raw_data_[23]); + if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { + ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, + this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]); return false; } return true; diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index efddccd3c5..66a4e04633 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -8,6 +8,8 @@ namespace esphome { namespace cse7766 { +static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; + class CSE7766Component : public Component, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } @@ -33,7 +35,7 @@ class CSE7766Component : public Component, public uart::UARTDevice { this->raw_data_[start_index + 2]); } - uint8_t raw_data_[24]; + uint8_t raw_data_[CSE7766_RAW_DATA_SIZE]; uint8_t raw_data_index_{0}; uint32_t last_transmission_{0}; sensor::Sensor *voltage_sensor_{nullptr}; From c7883cb5ae6f69ca7de1f4281c5661b41f98fb95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:06:38 -0600 Subject: [PATCH 18/37] [ld2450] Batch UART reads to reduce loop overhead (#13818) --- esphome/components/ld2450/ld2450.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index ca8d918441..38ba0d7f96 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -276,8 +276,19 @@ void LD2450Component::dump_config() { } void LD2450Component::loop() { - while (this->available()) { - this->readline_(this->read()); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } } } From 50fe8e51f9dfb610e74049fcc0db440e22301d54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:07:28 -0600 Subject: [PATCH 19/37] [ld2412] Batch UART reads to reduce loop overhead (#13819) --- esphome/components/ld2412/ld2412.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index c2f441e472..f8ceee78eb 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -310,8 +310,19 @@ void LD2412Component::restart_and_read_all_info() { } void LD2412Component::loop() { - while (this->available()) { - this->readline_(this->read()); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } } } From c43d3889b041ee1322232b1ea089c5cb5544d738 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:07:42 -0600 Subject: [PATCH 20/37] [modbus] Use stack buffer instead of heap vector in send() (#13853) --- esphome/components/modbus/modbus.cpp | 39 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5e9387b843..357cd48e11 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,39 +219,50 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address return; } - std::vector data; - data.push_back(address); - data.push_back(function_code); + static constexpr size_t ADDR_SIZE = 1; + static constexpr size_t FC_SIZE = 1; + static constexpr size_t START_ADDR_SIZE = 2; + static constexpr size_t NUM_ENTITIES_SIZE = 2; + static constexpr size_t BYTE_COUNT_SIZE = 1; + static constexpr size_t MAX_PAYLOAD_SIZE = std::numeric_limits::max(); + static constexpr size_t CRC_SIZE = 2; + static constexpr size_t MAX_FRAME_SIZE = + ADDR_SIZE + FC_SIZE + START_ADDR_SIZE + NUM_ENTITIES_SIZE + BYTE_COUNT_SIZE + MAX_PAYLOAD_SIZE + CRC_SIZE; + uint8_t data[MAX_FRAME_SIZE]; + size_t pos = 0; + + data[pos++] = address; + data[pos++] = function_code; if (this->role == ModbusRole::CLIENT) { - data.push_back(start_address >> 8); - data.push_back(start_address >> 0); + data[pos++] = start_address >> 8; + data[pos++] = start_address >> 0; if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data.push_back(number_of_entities >> 8); - data.push_back(number_of_entities >> 0); + data[pos++] = number_of_entities >> 8; + data[pos++] = number_of_entities >> 0; } } if (payload != nullptr) { if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple - data.push_back(payload_len); // Byte count is required for write + data[pos++] = payload_len; // Byte count is required for write } else { payload_len = 2; // Write single register or coil } for (int i = 0; i < payload_len; i++) { - data.push_back(payload[i]); + data[pos++] = payload[i]; } } - auto crc = crc16(data.data(), data.size()); - data.push_back(crc >> 0); - data.push_back(crc >> 8); + auto crc = crc16(data, pos); + data[pos++] = crc >> 0; + data[pos++] = crc >> 8; if (this->flow_control_pin_ != nullptr) this->flow_control_pin_->digital_write(true); - this->write_array(data); + this->write_array(data, pos); this->flush(); if (this->flow_control_pin_ != nullptr) @@ -261,7 +272,7 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Modbus write: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + ESP_LOGV(TAG, "Modbus write: %s", format_hex_pretty_to(hex_buf, data, pos)); } // Helper function for lambdas From d33f23dc43dad9c7891f2789e811dec248d83e5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:07:55 -0600 Subject: [PATCH 21/37] [ld2410] Batch UART reads to reduce loop overhead (#13820) --- esphome/components/ld2410/ld2410.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 5294f7cd36..b57b1d9978 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -275,8 +275,19 @@ void LD2410Component::restart_and_read_all_info() { } void LD2410Component::loop() { - while (this->available()) { - this->readline_(this->read()); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } } } From 8b24112be5aadb2952d7dff58cbfd05341349e29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:14:48 -0600 Subject: [PATCH 22/37] [pipsolar] Batch UART reads to reduce per-loop overhead (#13829) --- esphome/components/pipsolar/pipsolar.cpp | 64 +++++++++++++++--------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index bafd5273da..d7b37f6130 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -13,9 +13,12 @@ void Pipsolar::setup() { } void Pipsolar::empty_uart_buffer_() { - uint8_t byte; - while (this->available()) { - this->read_byte(&byte); + uint8_t buf[64]; + int avail; + while ((avail = this->available()) > 0) { + if (!this->read_array(buf, std::min(static_cast(avail), sizeof(buf)))) { + break; + } } } @@ -94,32 +97,47 @@ void Pipsolar::loop() { } if (this->state_ == STATE_COMMAND || this->state_ == STATE_POLL) { - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - - // make sure data and null terminator fit in buffer - if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { - this->read_pos_ = 0; - this->empty_uart_buffer_(); - ESP_LOGW(TAG, "response data too long, discarding."); + int avail = this->available(); + while (avail > 0) { + uint8_t buf[64]; + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { break; } - this->read_buffer_[this->read_pos_] = byte; - this->read_pos_++; + avail -= to_read; + bool done = false; + for (size_t i = 0; i < to_read; i++) { + uint8_t byte = buf[i]; - // end of answer - if (byte == 0x0D) { - this->read_buffer_[this->read_pos_] = 0; - this->empty_uart_buffer_(); - if (this->state_ == STATE_POLL) { - this->state_ = STATE_POLL_COMPLETE; + // make sure data and null terminator fit in buffer + if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { + this->read_pos_ = 0; + this->empty_uart_buffer_(); + ESP_LOGW(TAG, "response data too long, discarding."); + done = true; + break; } - if (this->state_ == STATE_COMMAND) { - this->state_ = STATE_COMMAND_COMPLETE; + this->read_buffer_[this->read_pos_] = byte; + this->read_pos_++; + + // end of answer + if (byte == 0x0D) { + this->read_buffer_[this->read_pos_] = 0; + this->empty_uart_buffer_(); + if (this->state_ == STATE_POLL) { + this->state_ = STATE_POLL_COMPLETE; + } + if (this->state_ == STATE_COMMAND) { + this->state_ = STATE_COMMAND_COMPLETE; + } + done = true; + break; } } - } // available + if (done) { + break; + } + } } if (this->state_ == STATE_COMMAND) { if (millis() - this->command_start_millis_ > esphome::pipsolar::Pipsolar::COMMAND_TIMEOUT) { From 623f33c9f9ea480d35d258b34c2fca427ce54ae4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:04 -0600 Subject: [PATCH 23/37] [rd03d] Batch UART reads to reduce per-loop overhead (#13830) --- esphome/components/rd03d/rd03d.cpp | 67 +++++++++++++++++------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 090e4dcf32..e4dbdf41cb 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -1,4 +1,5 @@ #include "rd03d.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -80,37 +81,47 @@ void RD03DComponent::dump_config() { } void RD03DComponent::loop() { - while (this->available()) { - uint8_t byte = this->read(); - ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t i = 0; i < to_read; i++) { + uint8_t byte = buf[i]; + ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); - // Check if we're looking for frame header - if (this->buffer_pos_ < FRAME_HEADER_SIZE) { - if (byte == FRAME_HEADER[this->buffer_pos_]) { - this->buffer_[this->buffer_pos_++] = byte; - } else if (byte == FRAME_HEADER[0]) { - // Start over if we see a potential new header - this->buffer_[0] = byte; - this->buffer_pos_ = 1; - } else { + // Check if we're looking for frame header + if (this->buffer_pos_ < FRAME_HEADER_SIZE) { + if (byte == FRAME_HEADER[this->buffer_pos_]) { + this->buffer_[this->buffer_pos_++] = byte; + } else if (byte == FRAME_HEADER[0]) { + // Start over if we see a potential new header + this->buffer_[0] = byte; + this->buffer_pos_ = 1; + } else { + this->buffer_pos_ = 0; + } + continue; + } + + // Accumulate data bytes + this->buffer_[this->buffer_pos_++] = byte; + + // Check if we have a complete frame + if (this->buffer_pos_ == FRAME_SIZE) { + // Validate footer + if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { + this->process_frame_(); + } else { + ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], + this->buffer_[FRAME_SIZE - 1]); + } this->buffer_pos_ = 0; } - continue; - } - - // Accumulate data bytes - this->buffer_[this->buffer_pos_++] = byte; - - // Check if we have a complete frame - if (this->buffer_pos_ == FRAME_SIZE) { - // Validate footer - if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { - this->process_frame_(); - } else { - ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], - this->buffer_[FRAME_SIZE - 1]); - } - this->buffer_pos_ = 0; } } } From e7a900fbaa082c906dcb54f438a876da11763456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:15 -0600 Subject: [PATCH 24/37] [rf_bridge] Batch UART reads to reduce per-loop overhead (#13831) --- esphome/components/rf_bridge/rf_bridge.cpp | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 8105767485..e33c13aafe 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -136,14 +136,21 @@ void RFBridgeComponent::loop() { this->last_bridge_byte_ = now; } - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - if (this->parse_bridge_byte_(byte)) { - ESP_LOGVV(TAG, "Parsed: 0x%02X", byte); - this->last_bridge_byte_ = now; - } else { - this->rx_buffer_.clear(); + int avail = this->available(); + while (avail > 0) { + uint8_t buf[64]; + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t i = 0; i < to_read; i++) { + if (this->parse_bridge_byte_(buf[i])) { + ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); + this->last_bridge_byte_ = now; + } else { + this->rx_buffer_.clear(); + } } } } From e176cf50abb2acfb911d72c8f1c9445530542a5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:28 -0600 Subject: [PATCH 25/37] [dfplayer] Batch UART reads to reduce per-loop overhead (#13832) --- esphome/components/dfplayer/dfplayer.cpp | 264 ++++++++++++----------- 1 file changed, 137 insertions(+), 127 deletions(-) diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 70bd42e1a5..48c06be558 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -1,4 +1,5 @@ #include "dfplayer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -131,140 +132,149 @@ void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) { } void DFPlayer::loop() { - // Read message - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t bi = 0; bi < to_read; bi++) { + uint8_t byte = buf[bi]; - if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH) - this->read_pos_ = 0; + if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH) + this->read_pos_ = 0; - switch (this->read_pos_) { - case 0: // Start mark - if (byte != 0x7E) - continue; - break; - case 1: // Version - if (byte != 0xFF) { - ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - break; - case 2: // Buffer length - if (byte != 0x06) { - ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - break; - case 9: // End byte + switch (this->read_pos_) { + case 0: // Start mark + if (byte != 0x7E) + continue; + break; + case 1: // Version + if (byte != 0xFF) { + ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + break; + case 2: // Buffer length + if (byte != 0x06) { + ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + break; + case 9: // End byte #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char byte_sequence[100]; - byte_sequence[0] = '\0'; - for (size_t i = 0; i < this->read_pos_ + 1; ++i) { - snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ", - this->read_buffer_[i]); - } - ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence); + char byte_sequence[100]; + byte_sequence[0] = '\0'; + for (size_t i = 0; i < this->read_pos_ + 1; ++i) { + snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ", + this->read_buffer_[i]); + } + ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence); #endif - if (byte != 0xEF) { - ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte); + if (byte != 0xEF) { + ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + // Parse valid received command + uint8_t cmd = this->read_buffer_[3]; + uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6]; + + ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument); + + switch (cmd) { + case 0x3A: + if (argument == 1) { + ESP_LOGI(TAG, "USB loaded"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card loaded"); + } + break; + case 0x3B: + if (argument == 1) { + ESP_LOGI(TAG, "USB unloaded"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card unloaded"); + } + break; + case 0x3F: + if (argument == 1) { + ESP_LOGI(TAG, "USB available"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card available"); + } else if (argument == 3) { + ESP_LOGI(TAG, "USB, TF Card available"); + } + break; + case 0x40: + ESP_LOGV(TAG, "Nack"); + this->ack_set_is_playing_ = false; + this->ack_reset_is_playing_ = false; + switch (argument) { + case 0x01: + ESP_LOGE(TAG, "Module is busy or uninitialized"); + break; + case 0x02: + ESP_LOGE(TAG, "Module is in sleep mode"); + break; + case 0x03: + ESP_LOGE(TAG, "Serial receive error"); + break; + case 0x04: + ESP_LOGE(TAG, "Checksum incorrect"); + break; + case 0x05: + ESP_LOGE(TAG, "Specified track is out of current track scope"); + this->is_playing_ = false; + break; + case 0x06: + ESP_LOGE(TAG, "Specified track is not found"); + this->is_playing_ = false; + break; + case 0x07: + ESP_LOGE(TAG, + "Insertion error (an inserting operation only can be done when a track is being played)"); + break; + case 0x08: + ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)"); + break; + case 0x09: + ESP_LOGE(TAG, "Entered into sleep mode"); + this->is_playing_ = false; + break; + } + break; + case 0x41: + ESP_LOGV(TAG, "Ack ok"); + this->is_playing_ |= this->ack_set_is_playing_; + this->is_playing_ &= !this->ack_reset_is_playing_; + this->ack_set_is_playing_ = false; + this->ack_reset_is_playing_ = false; + break; + case 0x3C: + ESP_LOGV(TAG, "Playback finished (USB drive)"); + this->is_playing_ = false; + this->on_finished_playback_callback_.call(); + case 0x3D: + ESP_LOGV(TAG, "Playback finished (SD card)"); + this->is_playing_ = false; + this->on_finished_playback_callback_.call(); + break; + default: + ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument); + } + this->sent_cmd_ = 0; this->read_pos_ = 0; continue; - } - // Parse valid received command - uint8_t cmd = this->read_buffer_[3]; - uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6]; - - ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument); - - switch (cmd) { - case 0x3A: - if (argument == 1) { - ESP_LOGI(TAG, "USB loaded"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card loaded"); - } - break; - case 0x3B: - if (argument == 1) { - ESP_LOGI(TAG, "USB unloaded"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card unloaded"); - } - break; - case 0x3F: - if (argument == 1) { - ESP_LOGI(TAG, "USB available"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card available"); - } else if (argument == 3) { - ESP_LOGI(TAG, "USB, TF Card available"); - } - break; - case 0x40: - ESP_LOGV(TAG, "Nack"); - this->ack_set_is_playing_ = false; - this->ack_reset_is_playing_ = false; - switch (argument) { - case 0x01: - ESP_LOGE(TAG, "Module is busy or uninitialized"); - break; - case 0x02: - ESP_LOGE(TAG, "Module is in sleep mode"); - break; - case 0x03: - ESP_LOGE(TAG, "Serial receive error"); - break; - case 0x04: - ESP_LOGE(TAG, "Checksum incorrect"); - break; - case 0x05: - ESP_LOGE(TAG, "Specified track is out of current track scope"); - this->is_playing_ = false; - break; - case 0x06: - ESP_LOGE(TAG, "Specified track is not found"); - this->is_playing_ = false; - break; - case 0x07: - ESP_LOGE(TAG, "Insertion error (an inserting operation only can be done when a track is being played)"); - break; - case 0x08: - ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)"); - break; - case 0x09: - ESP_LOGE(TAG, "Entered into sleep mode"); - this->is_playing_ = false; - break; - } - break; - case 0x41: - ESP_LOGV(TAG, "Ack ok"); - this->is_playing_ |= this->ack_set_is_playing_; - this->is_playing_ &= !this->ack_reset_is_playing_; - this->ack_set_is_playing_ = false; - this->ack_reset_is_playing_ = false; - break; - case 0x3C: - ESP_LOGV(TAG, "Playback finished (USB drive)"); - this->is_playing_ = false; - this->on_finished_playback_callback_.call(); - case 0x3D: - ESP_LOGV(TAG, "Playback finished (SD card)"); - this->is_playing_ = false; - this->on_finished_playback_callback_.call(); - break; - default: - ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument); - } - this->sent_cmd_ = 0; - this->read_pos_ = 0; - continue; + } + this->read_buffer_[this->read_pos_] = byte; + this->read_pos_++; } - this->read_buffer_[this->read_pos_] = byte; - this->read_pos_++; } } void DFPlayer::dump_config() { From a5ee4510433c247e8eebe982ff82d587a2e303f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:17:58 -0600 Subject: [PATCH 26/37] [tuya] Batch UART reads to reduce per-loop overhead (#13827) --- esphome/components/tuya/tuya.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 2812fb6ad6..9ee4c09b86 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -31,10 +31,19 @@ void Tuya::setup() { } void Tuya::loop() { - while (this->available()) { - uint8_t c; - this->read_byte(&c); - this->handle_char_(c); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->handle_char_(buf[i]); + } } process_command_queue_(); } From 8fffe7453dd4cca340808964e8cd7ea24c8002df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:18:12 -0600 Subject: [PATCH 27/37] [seeed_mr24hpc1/mr60fda2/mr60bha2] Batch UART reads to reduce per-loop overhead (#13825) --- .../seeed_mr24hpc1/seeed_mr24hpc1.cpp | 17 ++++++++++----- .../seeed_mr60bha2/seeed_mr60bha2.cpp | 21 ++++++++++++------- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 17 ++++++++++----- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 08d83f9390..3f2103b401 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -106,12 +106,19 @@ void MR24HPC1Component::update_() { // main loop void MR24HPC1Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->r24_split_data_frame_(byte); // split data frame + for (size_t i = 0; i < to_read; i++) { + this->r24_split_data_frame_(buf[i]); // split data frame + } } if ((this->s_output_info_switch_flag_ == OUTPUT_SWTICH_OFF) && diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index b9ce1f9151..d95e13241d 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -30,14 +30,21 @@ void MR60BHA2Component::dump_config() { // main loop void MR60BHA2Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->rx_message_.push_back(byte); - if (!this->validate_message_()) { - this->rx_message_.clear(); + for (size_t i = 0; i < to_read; i++) { + this->rx_message_.push_back(buf[i]); + if (!this->validate_message_()) { + this->rx_message_.clear(); + } } } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index b5b5b4d05a..441ee2b5c2 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -49,12 +49,19 @@ void MR60FDA2Component::setup() { // main loop void MR60FDA2Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->split_frame_(byte); // split data frame + for (size_t i = 0; i < to_read; i++) { + this->split_frame_(buf[i]); // split data frame + } } } From 4a9ff48f0246a4bd6131f19a76fce42db9af8781 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:20:50 -0600 Subject: [PATCH 28/37] [nextion] Batch UART reads to reduce loop overhead (#13823) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index fd6ce0a24b..56bbc840fb 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -397,11 +397,17 @@ bool Nextion::remove_from_q_(bool report_empty) { } void Nextion::process_serial_() { - uint8_t d; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - while (this->available()) { - read_byte(&d); - this->command_data_ += d; + this->command_data_.append(reinterpret_cast(buf), to_read); } } // nextion.tech/instruction-set/ From cd55eb927ddba835775dd71f2da7e2cdb7b59ea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:21:15 -0600 Subject: [PATCH 29/37] [modbus] Batch UART reads to reduce loop overhead (#13822) --- esphome/components/modbus/modbus.cpp | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 357cd48e11..c1f5635028 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -19,16 +19,25 @@ void Modbus::setup() { void Modbus::loop() { const uint32_t now = App.get_loop_component_start_time(); - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - if (this->parse_modbus_byte_(byte)) { - this->last_modbus_byte_ = now; - } else { - size_t at = this->rx_buffer_.size(); - if (at > 0) { - ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at); - this->rx_buffer_.clear(); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + if (this->parse_modbus_byte_(buf[i])) { + this->last_modbus_byte_ = now; + } else { + size_t at = this->rx_buffer_.size(); + if (at > 0) { + ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at); + this->rx_buffer_.clear(); + } } } } From 41a9588d811088ad72e9e6655c29256aa597b0c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:26:06 -0600 Subject: [PATCH 30/37] [i2c] Replace switch with if-else to avoid CSWTCH table in RAM (#13815) --- esphome/components/i2c/i2c_bus_arduino.cpp | 34 ++++++++++------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index e728830147..edd6b81588 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -134,25 +134,23 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe for (size_t j = 0; j != read_count; j++) read_buffer[j] = wire_->read(); } - switch (status) { - case 0: - return ERROR_OK; - case 1: - // transmit buffer not large enough - ESP_LOGVV(TAG, "TX failed: buffer not large enough"); - return ERROR_UNKNOWN; - case 2: - case 3: - ESP_LOGVV(TAG, "TX failed: not acknowledged: %d", status); - return ERROR_NOT_ACKNOWLEDGED; - case 5: - ESP_LOGVV(TAG, "TX failed: timeout"); - return ERROR_UNKNOWN; - case 4: - default: - ESP_LOGVV(TAG, "TX failed: unknown error %u", status); - return ERROR_UNKNOWN; + // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 + if (status == 0) + return ERROR_OK; + if (status == 1) { + ESP_LOGVV(TAG, "TX failed: buffer not large enough"); + return ERROR_UNKNOWN; } + if (status == 2 || status == 3) { + ESP_LOGVV(TAG, "TX failed: not acknowledged: %u", status); + return ERROR_NOT_ACKNOWLEDGED; + } + if (status == 5) { + ESP_LOGVV(TAG, "TX failed: timeout"); + return ERROR_UNKNOWN; + } + ESP_LOGVV(TAG, "TX failed: unknown error %u", status); + return ERROR_UNKNOWN; } /// Perform I2C bus recovery, see: From e4ea016d1e6a7cb7a93f12c6cd2c1e419858c899 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:26:19 -0600 Subject: [PATCH 31/37] [ci] Block new std::to_string() usage, suggest snprintf alternatives (#13369) --- .../components/api/homeassistant_service.h | 4 +- esphome/components/esp32_ble/ble_uuid.h | 2 +- .../voice_assistant/voice_assistant.h | 2 +- script/ci-custom.py | 47 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 8ee23c75fe..2322d96eef 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -25,7 +25,9 @@ template class TemplatableStringValue : public TemplatableValue static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } + template static std::string value_to_string(T &&val) { + return to_string(std::forward(val)); // NOLINT + } // Overloads for string types - needed because std::to_string doesn't support them static std::string value_to_string(char *val) { diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 6c8ef7bfd9..503fde6945 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -48,7 +48,7 @@ class ESPBTUUID { // Remove before 2026.8.0 ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; + std::string to_string() const; // NOLINT const char *to_str(std::span output) const; protected: diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 2a5f3a55a7..0ef7ecc81a 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -83,7 +83,7 @@ struct Timer { } // Remove before 2026.8.0 ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const { + std::string to_string() const { // NOLINT char buffer[TO_STR_BUFFER_SIZE]; return this->to_str(buffer); } diff --git a/script/ci-custom.py b/script/ci-custom.py index b5bec74fa7..8c405b04ae 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -756,6 +756,53 @@ def lint_no_sprintf(fname, match): ) +@lint_re_check( + # Match std::to_string() or unqualified to_string() calls + # The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string + # Use negative lookbehind for unqualified calls to avoid matching: + # - Function definitions: "const char *to_string(" or "std::string to_string(" + # - Method definitions: "Class::to_string(" + # - Method calls: ".to_string(" or "->to_string(" + # - Other identifiers: "_to_string(" + # Also explicitly match std::to_string since : is in the lookbehind + r"(?:(?:])to_string|std\s*::\s*to_string)\s*\(" + CPP_RE_EOL, + include=cpp_include, + exclude=[ + # Vendored library + "esphome/components/http_request/httplib.h", + # Deprecated helpers that return std::string + "esphome/core/helpers.cpp", + # The using declaration itself + "esphome/core/helpers.h", + # Test fixtures - not production embedded code + "tests/integration/fixtures/*", + ], +) +def lint_no_std_to_string(fname, match): + return ( + f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " + f"allocates heap memory. On long-running embedded devices, repeated heap allocations " + f"fragment memory over time.\n" + f"Please use {highlight('snprintf()')} with a stack buffer instead.\n" + f"\n" + f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" + f" uint8_t: 4 chars - %u (or PRIu8)\n" + f" int8_t: 5 chars - %d (or PRId8)\n" + f" uint16_t: 6 chars - %u (or PRIu16)\n" + f" int16_t: 7 chars - %d (or PRId16)\n" + f" uint32_t: 11 chars - %" + "PRIu32\n" + " int32_t: 12 chars - %" + "PRId32\n" + " uint64_t: 21 chars - %" + "PRIu64\n" + " int64_t: 21 chars - %" + "PRId64\n" + f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" + f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" + f"\n" + f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n" + f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ) + + @lint_re_check( # Match scanf family functions: scanf, sscanf, fscanf, vscanf, vsscanf, vfscanf # Also match std:: prefixed versions From 6c6da8a3cd2ab8dd41c6cd20cbed8884a30a7906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:45:24 -0600 Subject: [PATCH 32/37] [api] Skip class generation for empty SOURCE_CLIENT protobuf messages (#13880) --- esphome/components/api/api_pb2.h | 91 ---------------------- esphome/components/api/api_pb2_dump.cpp | 28 ------- esphome/components/api/api_pb2_service.cpp | 16 ++-- script/api_protobuf/api_protobuf.py | 53 ++++++++++--- 4 files changed, 52 insertions(+), 136 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index cf6c65f285..15819da172 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -440,19 +440,6 @@ class PingResponse final : public ProtoMessage { protected: }; -class DeviceInfoRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 9; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "device_info_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; #ifdef USE_AREAS class AreaInfo final : public ProtoMessage { public: @@ -546,19 +533,6 @@ class DeviceInfoResponse final : public ProtoMessage { protected: }; -class ListEntitiesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 11; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "list_entities_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; @@ -572,19 +546,6 @@ class ListEntitiesDoneResponse final : public ProtoMessage { protected: }; -class SubscribeStatesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 20; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_states_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: @@ -1037,19 +998,6 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -class SubscribeHomeassistantServicesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 34; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_homeassistant_services_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key{}; @@ -1117,19 +1065,6 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_STATES -class SubscribeHomeAssistantStatesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 38; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_home_assistant_states_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class SubscribeHomeAssistantStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 39; @@ -2160,19 +2095,6 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { protected: }; -class SubscribeBluetoothConnectionsFreeRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 80; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_bluetooth_connections_free_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class BluetoothConnectionsFreeResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; @@ -2279,19 +2201,6 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { protected: }; -class UnsubscribeBluetoothLEAdvertisementsRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 87; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "unsubscribe_bluetooth_le_advertisements_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class BluetoothDeviceClearCacheResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 88; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index e9db36ae21..f1e3bdcafe 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -764,10 +764,6 @@ const char *PingResponse::dump_to(DumpBuffer &out) const { out.append("PingResponse {}"); return out.c_str(); } -const char *DeviceInfoRequest::dump_to(DumpBuffer &out) const { - out.append("DeviceInfoRequest {}"); - return out.c_str(); -} #ifdef USE_AREAS const char *AreaInfo::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "AreaInfo"); @@ -848,18 +844,10 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } -const char *ListEntitiesRequest::dump_to(DumpBuffer &out) const { - out.append("ListEntitiesRequest {}"); - return out.c_str(); -} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append("ListEntitiesDoneResponse {}"); return out.c_str(); } -const char *SubscribeStatesRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeStatesRequest {}"); - return out.c_str(); -} #ifdef USE_BINARY_SENSOR const char *ListEntitiesBinarySensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "ListEntitiesBinarySensorResponse"); @@ -1191,10 +1179,6 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const { } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -const char *SubscribeHomeassistantServicesRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeHomeassistantServicesRequest {}"); - return out.c_str(); -} const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "HomeassistantServiceMap"); dump_field(out, "key", this->key); @@ -1245,10 +1229,6 @@ const char *HomeassistantActionResponse::dump_to(DumpBuffer &out) const { } #endif #ifdef USE_API_HOMEASSISTANT_STATES -const char *SubscribeHomeAssistantStatesRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeHomeAssistantStatesRequest {}"); - return out.c_str(); -} const char *SubscribeHomeAssistantStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "SubscribeHomeAssistantStateResponse"); dump_field(out, "entity_id", this->entity_id); @@ -1924,10 +1904,6 @@ const char *BluetoothGATTNotifyDataResponse::dump_to(DumpBuffer &out) const { dump_bytes_field(out, "data", this->data_ptr_, this->data_len_); return out.c_str(); } -const char *SubscribeBluetoothConnectionsFreeRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeBluetoothConnectionsFreeRequest {}"); - return out.c_str(); -} const char *BluetoothConnectionsFreeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "BluetoothConnectionsFreeResponse"); dump_field(out, "free", this->free); @@ -1970,10 +1946,6 @@ const char *BluetoothDeviceUnpairingResponse::dump_to(DumpBuffer &out) const { dump_field(out, "error", this->error); return out.c_str(); } -const char *UnsubscribeBluetoothLEAdvertisementsRequest::dump_to(DumpBuffer &out) const { - out.append("UnsubscribeBluetoothLEAdvertisementsRequest {}"); - return out.c_str(); -} const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "BluetoothDeviceClearCacheResponse"); dump_field(out, "address", this->address); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 2d15deb90d..f9151ae3b4 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -27,7 +27,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, case DisconnectRequest::MESSAGE_TYPE: // No setup required case PingRequest::MESSAGE_TYPE: // No setup required break; - case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only + case 9 /* DeviceInfoRequest is empty */: // Connection setup only if (!this->check_connection_setup_()) { return; } @@ -76,21 +76,21 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_ping_response(); break; } - case DeviceInfoRequest::MESSAGE_TYPE: { + case 9 /* DeviceInfoRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_device_info_request")); #endif this->on_device_info_request(); break; } - case ListEntitiesRequest::MESSAGE_TYPE: { + case 11 /* ListEntitiesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_list_entities_request")); #endif this->on_list_entities_request(); break; } - case SubscribeStatesRequest::MESSAGE_TYPE: { + case 20 /* SubscribeStatesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_states_request")); #endif @@ -151,7 +151,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES - case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { + case 34 /* SubscribeHomeassistantServicesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request")); #endif @@ -169,7 +169,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #ifdef USE_API_HOMEASSISTANT_STATES - case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { + case 38 /* SubscribeHomeAssistantStatesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request")); #endif @@ -376,7 +376,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #endif #ifdef USE_BLUETOOTH_PROXY - case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: { + case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); #endif @@ -385,7 +385,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #endif #ifdef USE_BLUETOOTH_PROXY - case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: { + case 87 /* UnsubscribeBluetoothLEAdvertisementsRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request")); #endif diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index ece0b5692f..4fbee49dae 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2277,6 +2277,12 @@ ifdefs: dict[str, str] = {} # Track messages with no fields (empty messages) for parameter elision EMPTY_MESSAGES: set[str] = set() +# Track empty SOURCE_CLIENT messages that don't need class generation +# These messages have no fields and are only received (never sent), so the +# class definition (vtable, dump_to, message_name, ESTIMATED_SIZE) is dead code +# that the compiler compiles but the linker strips away. +SKIP_CLASS_GENERATION: set[str] = set() + def get_opt( desc: descriptor.DescriptorProto, @@ -2527,7 +2533,11 @@ def build_service_message_type( case += "#endif\n" case += f"this->{func}({'msg' if not is_empty else ''});\n" case += "break;" - RECEIVE_CASES[id_] = (case, ifdef, mt.name) + if mt.name in SKIP_CLASS_GENERATION: + case_label = f"{id_} /* {mt.name} is empty */" + else: + case_label = f"{mt.name}::MESSAGE_TYPE" + RECEIVE_CASES[id_] = (case, ifdef, case_label) # Only close ifdef if we opened it if ifdef is not None: @@ -2723,6 +2733,19 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint mt = file.message_type + # Identify empty SOURCE_CLIENT messages that don't need class generation + for m in mt: + if m.options.deprecated: + continue + if not m.options.HasExtension(pb.id): + continue + source = message_source_map.get(m.name) + if source != SOURCE_CLIENT: + continue + has_fields = any(not field.options.deprecated for field in m.field) + if not has_fields: + SKIP_CLASS_GENERATION.add(m.name) + # Collect messages by base class base_class_groups = collect_messages_by_base_class(mt) @@ -2755,6 +2778,10 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint if m.name not in used_messages and not m.options.HasExtension(pb.id): continue + # Skip class generation for empty SOURCE_CLIENT messages + if m.name in SKIP_CLASS_GENERATION: + continue + s, c, dc = build_message_type(m, base_class_fields, message_source_map) msg_ifdef = message_ifdef_map.get(m.name) @@ -2901,10 +2928,18 @@ static const char *const TAG = "api.service"; no_conn_ids: set[int] = set() conn_only_ids: set[int] = set() - for id_, (_, _, case_msg_name) in cases: - if case_msg_name in message_auth_map: - needs_auth = message_auth_map[case_msg_name] - needs_conn = message_conn_map[case_msg_name] + # Build a reverse lookup from message id to message name for auth lookups + id_to_msg_name: dict[int, str] = {} + for mt in file.message_type: + id_ = get_opt(mt, pb.id) + if id_ is not None and not mt.options.deprecated: + id_to_msg_name[id_] = mt.name + + for id_, (_, _, case_label) in cases: + msg_name = id_to_msg_name.get(id_, "") + if msg_name in message_auth_map: + needs_auth = message_auth_map[msg_name] + needs_conn = message_conn_map[msg_name] if not needs_conn: no_conn_ids.add(id_) @@ -2915,10 +2950,10 @@ static const char *const TAG = "api.service"; def generate_cases(ids: set[int], comment: str) -> str: result = "" for id_ in sorted(ids): - _, ifdef, msg_name = RECEIVE_CASES[id_] + _, ifdef, case_label = RECEIVE_CASES[id_] if ifdef: result += f"#ifdef {ifdef}\n" - result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" + result += f" case {case_label}: {comment}\n" if ifdef: result += "#endif\n" return result @@ -2958,11 +2993,11 @@ static const char *const TAG = "api.service"; # Dispatch switch out += " switch (msg_type) {\n" - for i, (case, ifdef, message_name) in cases: + for i, (case, ifdef, case_label) in cases: if ifdef is not None: out += f"#ifdef {ifdef}\n" - c = f" case {message_name}::MESSAGE_TYPE: {{\n" + c = f" case {case_label}: {{\n" c += indent(case, " ") + "\n" c += " }" out += c + "\n" From e0712cc53b464ebf3af3c4cb811eabc11173524e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 13:16:22 -0600 Subject: [PATCH 33/37] [scheduler] Make core timer ID collisions impossible with type-safe internal IDs (#13882) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/binary_sensor/automation.cpp | 34 +++-- esphome/components/binary_sensor/filter.cpp | 39 ++++-- esphome/components/sensor/filter.cpp | 11 +- esphome/core/base_automation.h | 10 +- esphome/core/component.cpp | 16 ++- esphome/core/component.h | 14 ++ esphome/core/scheduler.cpp | 8 +- esphome/core/scheduler.h | 37 +++++- .../scheduler_internal_id_no_collision.yaml | 109 +++++++++++++++ ...test_scheduler_internal_id_no_collision.py | 124 ++++++++++++++++++ 10 files changed, 359 insertions(+), 43 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_internal_id_no_collision.yaml create mode 100644 tests/integration/test_scheduler_internal_id_no_collision.py diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index dfe911a2f8..faebe7e88f 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -5,6 +5,14 @@ namespace esphome::binary_sensor { static const char *const TAG = "binary_sensor.automation"; +// MultiClickTrigger timeout IDs. +// MultiClickTrigger is its own Component instance, so the scheduler scopes +// IDs by component pointer — no risk of collisions between instances. +constexpr uint32_t MULTICLICK_TRIGGER_ID = 0; +constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; +constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; +constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; + void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { @@ -27,7 +35,7 @@ void MultiClickTrigger::on_state_(bool state) { evt.min_length, evt.max_length); this->at_index_ = 1; if (this->timing_.size() == 1 && evt.max_length == 4294967294UL) { - this->set_timeout("trigger", evt.min_length, [this]() { this->trigger_(); }); + this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } else { this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); @@ -57,13 +65,13 @@ void MultiClickTrigger::on_state_(bool state) { this->schedule_is_not_valid_(evt.max_length); } else if (*this->at_index_ + 1 != this->timing_.size()) { ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT - this->cancel_timeout("is_not_valid"); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT this->is_valid_ = false; - this->cancel_timeout("is_not_valid"); - this->set_timeout("trigger", evt.min_length, [this]() { this->trigger_(); }); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); + this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } *this->at_index_ = *this->at_index_ + 1; @@ -71,14 +79,14 @@ void MultiClickTrigger::on_state_(bool state) { void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; - this->set_timeout("cooldown", this->invalid_cooldown_, [this]() { + this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again."); this->is_in_cooldown_ = false; }); this->at_index_.reset(); - this->cancel_timeout("trigger"); - this->cancel_timeout("is_valid"); - this->cancel_timeout("is_not_valid"); + this->cancel_timeout(MULTICLICK_TRIGGER_ID); + this->cancel_timeout(MULTICLICK_IS_VALID_ID); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { @@ -86,13 +94,13 @@ void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { return; } this->is_valid_ = false; - this->set_timeout("is_valid", min_length, [this]() { + this->set_timeout(MULTICLICK_IS_VALID_ID, min_length, [this]() { ESP_LOGV(TAG, "Multi Click: You can now %s the button.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = true; }); } void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { - this->set_timeout("is_not_valid", max_length, [this]() { + this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = false; this->schedule_cooldown_(); @@ -106,9 +114,9 @@ void MultiClickTrigger::cancel() { void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); this->at_index_.reset(); - this->cancel_timeout("trigger"); - this->cancel_timeout("is_valid"); - this->cancel_timeout("is_not_valid"); + this->cancel_timeout(MULTICLICK_TRIGGER_ID); + this->cancel_timeout(MULTICLICK_IS_VALID_ID); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->trigger(); } diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 9c7238f6d7..d69671c5bf 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -6,6 +6,14 @@ namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; +// Timeout IDs for filter classes. +// Each filter is its own Component instance, so the scheduler scopes +// IDs by component pointer — no risk of collisions between instances. +constexpr uint32_t FILTER_TIMEOUT_ID = 0; +// AutorepeatFilter needs two distinct IDs (both timeouts on the same component) +constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; +constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; + void Filter::output(bool value) { if (this->next_ == nullptr) { this->parent_->send_state_internal(value); @@ -23,16 +31,16 @@ void Filter::input(bool value) { } void TimeoutFilter::input(bool value) { - this->set_timeout("timeout", this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); // we do not de-dup here otherwise changes from invalid to valid state will not be output this->output(value); } optional DelayedOnOffFilter::new_value(bool value) { if (value) { - this->set_timeout("ON_OFF", this->on_delay_.value(), [this]() { this->output(true); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); }); } else { - this->set_timeout("ON_OFF", this->off_delay_.value(), [this]() { this->output(false); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); }); } return {}; } @@ -41,10 +49,10 @@ float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HA optional DelayedOnFilter::new_value(bool value) { if (value) { - this->set_timeout("ON", this->delay_.value(), [this]() { this->output(true); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); }); return {}; } else { - this->cancel_timeout("ON"); + this->cancel_timeout(FILTER_TIMEOUT_ID); return false; } } @@ -53,10 +61,10 @@ float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDW optional DelayedOffFilter::new_value(bool value) { if (!value) { - this->set_timeout("OFF", this->delay_.value(), [this]() { this->output(false); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); }); return {}; } else { - this->cancel_timeout("OFF"); + this->cancel_timeout(FILTER_TIMEOUT_ID); return true; } } @@ -76,8 +84,8 @@ optional AutorepeatFilter::new_value(bool value) { this->next_timing_(); return true; } else { - this->cancel_timeout("TIMING"); - this->cancel_timeout("ON_OFF"); + this->cancel_timeout(AUTOREPEAT_TIMING_ID); + this->cancel_timeout(AUTOREPEAT_ON_OFF_ID); this->active_timing_ = 0; return false; } @@ -88,8 +96,10 @@ void AutorepeatFilter::next_timing_() { // 1st time: starts waiting the first delay // 2nd time: starts waiting the second delay and starts toggling with the first time_off / _on // last time: no delay to start but have to bump the index to reflect the last - if (this->active_timing_ < this->timings_.size()) - this->set_timeout("TIMING", this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); }); + if (this->active_timing_ < this->timings_.size()) { + this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay, + [this]() { this->next_timing_(); }); + } if (this->active_timing_ <= this->timings_.size()) { this->active_timing_++; @@ -104,7 +114,8 @@ void AutorepeatFilter::next_timing_() { void AutorepeatFilter::next_value_(bool val) { const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2]; this->output(val); // This is at least the second one so not initial - this->set_timeout("ON_OFF", val ? timing.time_on : timing.time_off, [this, val]() { this->next_value_(!val); }); + this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off, + [this, val]() { this->next_value_(!val); }); } float AutorepeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } @@ -115,7 +126,7 @@ optional LambdaFilter::new_value(bool value) { return this->f_(value); } optional SettleFilter::new_value(bool value) { if (!this->steady_) { - this->set_timeout("SETTLE", this->delay_.value(), [this, value]() { + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() { this->steady_ = true; this->output(value); }); @@ -123,7 +134,7 @@ optional SettleFilter::new_value(bool value) { } else { this->steady_ = false; this->output(value); - this->set_timeout("SETTLE", this->delay_.value(), [this]() { this->steady_ = true; }); + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; }); return value; } } diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 375505a557..ea0e2f0d7c 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -9,6 +9,11 @@ namespace esphome::sensor { static const char *const TAG = "sensor.filter"; +// Filter scheduler IDs. +// Each filter is its own Component instance, so the scheduler scopes +// IDs by component pointer — no risk of collisions between instances. +constexpr uint32_t FILTER_ID = 0; + // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -191,7 +196,7 @@ optional ThrottleAverageFilter::new_value(float value) { return {}; } void ThrottleAverageFilter::setup() { - this->set_interval("throttle_average", this->time_period_, [this]() { + this->set_interval(FILTER_ID, this->time_period_, [this]() { ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_); if (this->n_ == 0) { if (this->have_nan_) @@ -383,7 +388,7 @@ optional TimeoutFilterConfigured::new_value(float value) { // DebounceFilter optional DebounceFilter::new_value(float value) { - this->set_timeout("debounce", this->time_period_, [this, value]() { this->output(value); }); + this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); }); return {}; } @@ -406,7 +411,7 @@ optional HeartbeatFilter::new_value(float value) { } void HeartbeatFilter::setup() { - this->set_interval("heartbeat", this->time_period_, [this]() { + this->set_interval(FILTER_ID, this->time_period_, [this]() { ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_), this->last_input_); if (!this->has_value_) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 19d0ccf972..67e1755cc9 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -191,15 +191,17 @@ template class DelayAction : public Action, public Compon // instead of std::bind to avoid bind overhead (~16 bytes heap + faster execution) if constexpr (sizeof...(Ts) == 0) { App.scheduler.set_timer_common_( - this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::STATIC_STRING, "delay", 0, this->delay_.value(), + this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(), [this]() { this->play_next_(); }, /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } else { // For delays with arguments, use std::bind to preserve argument values // Arguments must be copied because original references may be invalid after delay auto f = std::bind(&DelayAction::play_next_, this, x...); - App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::STATIC_STRING, - "delay", 0, this->delay_.value(x...), std::move(f), + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, + nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), + this->delay_.value(x...), std::move(f), /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } } @@ -208,7 +210,7 @@ template class DelayAction : public Action, public Compon void play(const Ts &...x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout("delay"); } + void stop() override { this->cancel_timeout(InternalSchedulerID::DELAY_ACTION); } }; template class LambdaAction : public Action { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 6d8d1c57af..90aa36f4db 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -201,12 +201,24 @@ void Component::set_timeout(uint32_t id, uint32_t timeout, std::function bool Component::cancel_timeout(uint32_t id) { return App.scheduler.cancel_timeout(this, id); } +void Component::set_timeout(InternalSchedulerID id, uint32_t timeout, std::function &&f) { // NOLINT + App.scheduler.set_timeout(this, id, timeout, std::move(f)); +} + +bool Component::cancel_timeout(InternalSchedulerID id) { return App.scheduler.cancel_timeout(this, id); } + void Component::set_interval(uint32_t id, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, id, interval, std::move(f)); } bool Component::cancel_interval(uint32_t id) { return App.scheduler.cancel_interval(this, id); } +void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::function &&f) { // NOLINT + App.scheduler.set_interval(this, id, interval, std::move(f)); +} + +bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); } + void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT #pragma GCC diagnostic push @@ -533,12 +545,12 @@ void PollingComponent::call_setup() { void PollingComponent::start_poller() { // Register interval. - this->set_interval("update", this->get_update_interval(), [this]() { this->update(); }); + this->set_interval(InternalSchedulerID::POLLING_UPDATE, this->get_update_interval(), [this]() { this->update(); }); } void PollingComponent::stop_poller() { // Clear the interval to suspend component - this->cancel_interval("update"); + this->cancel_interval(InternalSchedulerID::POLLING_UPDATE); } uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } diff --git a/esphome/core/component.h b/esphome/core/component.h index c3582e23b1..9ab77cc2f9 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -49,6 +49,14 @@ extern const float LATE; static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; +/// Type-safe scheduler IDs for core base classes. +/// Uses a separate NameType (NUMERIC_ID_INTERNAL) so IDs can never collide +/// with component-level NUMERIC_ID values, even if the uint32_t values overlap. +enum class InternalSchedulerID : uint32_t { + POLLING_UPDATE = 0, // PollingComponent interval + DELAY_ACTION = 1, // DelayAction timeout +}; + // Forward declaration class PollingComponent; @@ -335,6 +343,8 @@ class Component { */ void set_interval(uint32_t id, uint32_t interval, std::function &&f); // NOLINT + void set_interval(InternalSchedulerID id, uint32_t interval, std::function &&f); // NOLINT + void set_interval(uint32_t interval, std::function &&f); // NOLINT /** Cancel an interval function. @@ -347,6 +357,7 @@ class Component { bool cancel_interval(const std::string &name); // NOLINT bool cancel_interval(const char *name); // NOLINT bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -425,6 +436,8 @@ class Component { */ void set_timeout(uint32_t id, uint32_t timeout, std::function &&f); // NOLINT + void set_timeout(InternalSchedulerID id, uint32_t timeout, std::function &&f); // NOLINT + void set_timeout(uint32_t timeout, std::function &&f); // NOLINT /** Cancel a timeout function. @@ -437,6 +450,7 @@ class Component { bool cancel_timeout(const std::string &name); // NOLINT bool cancel_timeout(const char *name); // NOLINT bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a5e308829a..97ac28b623 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -53,9 +53,12 @@ struct SchedulerNameLog { } else if (name_type == NameType::HASHED_STRING) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("hash:0x%08" PRIX32), hash_or_id); return buffer; - } else { // NUMERIC_ID + } else if (name_type == NameType::NUMERIC_ID) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id); return buffer; + } else { // NUMERIC_ID_INTERNAL + ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id); + return buffer; } } }; @@ -137,6 +140,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type case NameType::NUMERIC_ID: item->set_numeric_id(hash_or_id); break; + case NameType::NUMERIC_ID_INTERNAL: + item->set_internal_id(hash_or_id); + break; } item->type = type; item->callback = std::move(func); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 20b069f3f0..ede729d164 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -46,11 +46,20 @@ class Scheduler { void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func); /// Set a timeout with a numeric ID (zero heap allocation) void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func); + /// Set a timeout with an internal scheduler ID (separate namespace from component NUMERIC_ID) + void set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(id), timeout, std::move(func)); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); + bool cancel_timeout(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), + SchedulerItem::TIMEOUT); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") void set_interval(Component *component, const std::string &name, uint32_t interval, std::function func); @@ -66,11 +75,20 @@ class Scheduler { void set_interval(Component *component, const char *name, uint32_t interval, std::function func); /// Set an interval with a numeric ID (zero heap allocation) void set_interval(Component *component, uint32_t id, uint32_t interval, std::function func); + /// Set an interval with an internal scheduler ID (separate namespace from component NUMERIC_ID) + void set_interval(Component *component, InternalSchedulerID id, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(id), interval, std::move(func)); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); + bool cancel_interval(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), + SchedulerItem::INTERVAL); + } // Remove before 2026.8.0 ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", @@ -112,11 +130,12 @@ class Scheduler { void process_to_add(); // Name storage type discriminator for SchedulerItem - // Used to distinguish between static strings, hashed strings, and numeric IDs + // Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs enum class NameType : uint8_t { - STATIC_STRING = 0, // const char* pointer to static/flash storage - HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string - NUMERIC_ID = 2 // uint32_t numeric identifier + STATIC_STRING = 0, // const char* pointer to static/flash storage + HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string + NUMERIC_ID = 2, // uint32_t numeric identifier (component-level) + NUMERIC_ID_INTERNAL = 3 // uint32_t numeric identifier (core/internal, separate namespace) }; protected: @@ -147,7 +166,7 @@ class Scheduler { // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; - NameType name_type_ : 2; // Discriminator for name_ union (STATIC_STRING, HASHED_STRING, NUMERIC_ID) + NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) bool is_retry : 1; // True if this is a retry timeout // 4 bits padding #else @@ -155,7 +174,7 @@ class Scheduler { // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; - NameType name_type_ : 2; // Discriminator for name_ union (STATIC_STRING, HASHED_STRING, NUMERIC_ID) + NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) bool is_retry : 1; // True if this is a retry timeout // 3 bits padding #endif @@ -218,6 +237,12 @@ class Scheduler { name_type_ = NameType::NUMERIC_ID; } + // Helper to set an internal numeric ID (separate namespace from NUMERIC_ID) + void set_internal_id(uint32_t id) { + name_.hash_or_id = id; + name_type_ = NameType::NUMERIC_ID_INTERNAL; + } + static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. diff --git a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml new file mode 100644 index 0000000000..46dbb8e728 --- /dev/null +++ b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml @@ -0,0 +1,109 @@ +esphome: + name: scheduler-internal-id-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler internal ID collision tests" + +host: +api: +logger: + level: VERBOSE + +globals: + - id: tests_done + type: bool + initial_value: 'false' + +script: + - id: test_internal_id_no_collision + then: + - logger.log: "Testing NUMERIC_ID_INTERNAL vs NUMERIC_ID isolation" + - lambda: |- + // All tests use the same component and the same uint32_t value (0). + // NUMERIC_ID_INTERNAL and NUMERIC_ID are separate NameType values, + // so the scheduler must treat them as independent timers. + auto *comp = id(test_sensor); + + // ---- Test 1: Both timeout types fire independently ---- + // Set an internal timeout with ID 0 + App.scheduler.set_timeout(comp, InternalSchedulerID{0}, 50, []() { + ESP_LOGI("test", "Internal timeout 0 fired"); + }); + // Set a component numeric timeout with the same ID 0 + App.scheduler.set_timeout(comp, 0U, 50, []() { + ESP_LOGI("test", "Numeric timeout 0 fired"); + }); + + // ---- Test 2: Cancelling numeric ID does NOT cancel internal ID ---- + // Set an internal timeout with ID 1 + App.scheduler.set_timeout(comp, InternalSchedulerID{1}, 100, []() { + ESP_LOGI("test", "Internal timeout 1 survived cancel"); + }); + // Set a numeric timeout with the same ID 1 + App.scheduler.set_timeout(comp, 1U, 100, []() { + ESP_LOGE("test", "ERROR: Numeric timeout 1 should have been cancelled"); + }); + // Cancel only the numeric one + App.scheduler.cancel_timeout(comp, 1U); + + // ---- Test 3: Cancelling internal ID does NOT cancel numeric ID ---- + // Set a numeric timeout with ID 2 + App.scheduler.set_timeout(comp, 2U, 150, []() { + ESP_LOGI("test", "Numeric timeout 2 survived cancel"); + }); + // Set an internal timeout with the same ID 2 + App.scheduler.set_timeout(comp, InternalSchedulerID{2}, 150, []() { + ESP_LOGE("test", "ERROR: Internal timeout 2 should have been cancelled"); + }); + // Cancel only the internal one + App.scheduler.cancel_timeout(comp, InternalSchedulerID{2}); + + // ---- Test 4: Both interval types fire independently ---- + static int internal_interval_count = 0; + static int numeric_interval_count = 0; + App.scheduler.set_interval(comp, InternalSchedulerID{3}, 100, []() { + internal_interval_count++; + if (internal_interval_count == 2) { + ESP_LOGI("test", "Internal interval 3 fired twice"); + App.scheduler.cancel_interval(id(test_sensor), InternalSchedulerID{3}); + } + }); + App.scheduler.set_interval(comp, 3U, 100, []() { + numeric_interval_count++; + if (numeric_interval_count == 2) { + ESP_LOGI("test", "Numeric interval 3 fired twice"); + App.scheduler.cancel_interval(id(test_sensor), 3U); + } + }); + + // ---- Test 5: String name does NOT collide with internal ID ---- + // Use string name and internal ID 10 on same component + App.scheduler.set_timeout(comp, "collision_test", 200, []() { + ESP_LOGI("test", "String timeout collision_test fired"); + }); + App.scheduler.set_timeout(comp, InternalSchedulerID{10}, 200, []() { + ESP_LOGI("test", "Internal timeout 10 fired"); + }); + + // Log completion after all timers should have fired + App.scheduler.set_timeout(comp, 9999U, 1500, []() { + ESP_LOGI("test", "All collision tests complete"); + }); + +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 1.0; + update_interval: never + +interval: + - interval: 0.1s + then: + - if: + condition: + lambda: 'return id(tests_done) == false;' + then: + - lambda: 'id(tests_done) = true;' + - script.execute: test_internal_id_no_collision diff --git a/tests/integration/test_scheduler_internal_id_no_collision.py b/tests/integration/test_scheduler_internal_id_no_collision.py new file mode 100644 index 0000000000..d30e725e00 --- /dev/null +++ b/tests/integration/test_scheduler_internal_id_no_collision.py @@ -0,0 +1,124 @@ +"""Test that NUMERIC_ID_INTERNAL and NUMERIC_ID cannot collide. + +Verifies that InternalSchedulerID (used by core base classes like +PollingComponent and DelayAction) and uint32_t numeric IDs (used by +components) are in completely separate matching namespaces, even when +the underlying uint32_t values are identical and on the same component. +""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_internal_id_no_collision( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that internal and numeric IDs with same value don't collide.""" + # Test 1: Both types fire independently with same ID + internal_timeout_0_fired = asyncio.Event() + numeric_timeout_0_fired = asyncio.Event() + + # Test 2: Cancelling numeric doesn't cancel internal + internal_timeout_1_survived = asyncio.Event() + numeric_timeout_1_error = asyncio.Event() + + # Test 3: Cancelling internal doesn't cancel numeric + numeric_timeout_2_survived = asyncio.Event() + internal_timeout_2_error = asyncio.Event() + + # Test 4: Both interval types fire independently + internal_interval_3_done = asyncio.Event() + numeric_interval_3_done = asyncio.Event() + + # Test 5: String name doesn't collide with internal ID + string_timeout_fired = asyncio.Event() + internal_timeout_10_fired = asyncio.Event() + + # Completion + all_tests_complete = asyncio.Event() + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "Internal timeout 0 fired" in clean_line: + internal_timeout_0_fired.set() + elif "Numeric timeout 0 fired" in clean_line: + numeric_timeout_0_fired.set() + elif "Internal timeout 1 survived cancel" in clean_line: + internal_timeout_1_survived.set() + elif "ERROR: Numeric timeout 1 should have been cancelled" in clean_line: + numeric_timeout_1_error.set() + elif "Numeric timeout 2 survived cancel" in clean_line: + numeric_timeout_2_survived.set() + elif "ERROR: Internal timeout 2 should have been cancelled" in clean_line: + internal_timeout_2_error.set() + elif "Internal interval 3 fired twice" in clean_line: + internal_interval_3_done.set() + elif "Numeric interval 3 fired twice" in clean_line: + numeric_interval_3_done.set() + elif "String timeout collision_test fired" in clean_line: + string_timeout_fired.set() + elif "Internal timeout 10 fired" in clean_line: + internal_timeout_10_fired.set() + elif "All collision tests complete" in clean_line: + all_tests_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-internal-id-test" + + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("Not all collision tests completed within 5 seconds") + + # Test 1: Both timeout types with same ID 0 must fire + assert internal_timeout_0_fired.is_set(), ( + "Internal timeout with ID 0 should have fired" + ) + assert numeric_timeout_0_fired.is_set(), ( + "Numeric timeout with ID 0 should have fired" + ) + + # Test 2: Cancelling numeric ID must NOT cancel internal ID + assert internal_timeout_1_survived.is_set(), ( + "Internal timeout 1 should survive cancellation of numeric timeout 1" + ) + assert not numeric_timeout_1_error.is_set(), ( + "Numeric timeout 1 should have been cancelled" + ) + + # Test 3: Cancelling internal ID must NOT cancel numeric ID + assert numeric_timeout_2_survived.is_set(), ( + "Numeric timeout 2 should survive cancellation of internal timeout 2" + ) + assert not internal_timeout_2_error.is_set(), ( + "Internal timeout 2 should have been cancelled" + ) + + # Test 4: Both interval types with same ID must fire independently + assert internal_interval_3_done.is_set(), ( + "Internal interval 3 should have fired at least twice" + ) + assert numeric_interval_3_done.is_set(), ( + "Numeric interval 3 should have fired at least twice" + ) + + # Test 5: String name and internal ID don't collide + assert string_timeout_fired.is_set(), ( + "String timeout 'collision_test' should have fired" + ) + assert internal_timeout_10_fired.is_set(), ( + "Internal timeout 10 should have fired alongside string timeout" + ) From 00256e3ca0bf29f7ab3c7226b9a92aa292848394 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:35:41 +1100 Subject: [PATCH 34/37] [mipi_rgb] Allow use on P4 (#13740) --- esphome/components/mipi_rgb/display.py | 4 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 4 +- esphome/components/mipi_rgb/mipi_rgb.h | 6 +- tests/components/mipi_rgb/common.yaml | 52 +++++++++++++++++ .../mipi_rgb/test.esp32-p4-idf.yaml | 6 ++ .../mipi_rgb/test.esp32-s3-idf.yaml | 56 +------------------ .../common/spi/esp32-p4-idf.yaml | 12 ++++ 7 files changed, 78 insertions(+), 62 deletions(-) create mode 100644 tests/components/mipi_rgb/common.yaml create mode 100644 tests/components/mipi_rgb/test.esp32-p4-idf.yaml create mode 100644 tests/test_build_components/common/spi/esp32-p4-idf.yaml diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 084fe6de14..24988cfcf8 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -11,7 +11,7 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD -from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( COLOR_ORDERS, CONF_DE_PIN, @@ -225,7 +225,7 @@ def _config_schema(config): return cv.All( schema, cv.only_on_esp32, - only_on_variant(supported=[VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index d0e716bd24..7ff6868c15 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP32_VARIANT_ESP32S3 +#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "mipi_rgb.h" #include "esphome/core/gpio.h" #include "esphome/core/hal.h" @@ -401,4 +401,4 @@ void MipiRgb::dump_config() { } // namespace mipi_rgb } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S3 +#endif // defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 173e23752d..76b48bb249 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP32_VARIANT_ESP32S3 +#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "esphome/core/gpio.h" #include "esphome/components/display/display.h" #include "esp_lcd_panel_ops.h" @@ -28,7 +28,7 @@ class MipiRgb : public display::Display { void setup() override; void loop() override; void update() override; - void fill(Color color); + void fill(Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, @@ -115,7 +115,7 @@ class MipiRgbSpi : public MipiRgb, void write_command_(uint8_t value); void write_data_(uint8_t value); void write_init_sequence_(); - void dump_config(); + void dump_config() override; GPIOPin *dc_pin_{nullptr}; std::vector init_sequence_; diff --git a/tests/components/mipi_rgb/common.yaml b/tests/components/mipi_rgb/common.yaml new file mode 100644 index 0000000000..c137bc6af3 --- /dev/null +++ b/tests/components/mipi_rgb/common.yaml @@ -0,0 +1,52 @@ +display: + - platform: mipi_rgb + spi_id: spi_bus + model: ZX2D10GE01R-V4848 + update_interval: 1s + color_order: BGR + draw_rounding: 2 + pixel_mode: 18bit + invert_colors: false + use_axis_flips: true + pclk_frequency: 15000000.0 + pclk_inverted: true + byte_order: big_endian + hsync_pulse_width: 10 + hsync_back_porch: 10 + hsync_front_porch: 10 + vsync_pulse_width: 2 + vsync_back_porch: 12 + vsync_front_porch: 14 + data_pins: + red: + - number: 10 + - number: 16 + - number: 9 + - number: 15 + - number: 46 + green: + - number: 8 + - number: 13 + - number: 18 + - number: 12 + - number: 11 + - number: 17 + blue: + - number: 47 + - number: 1 + - number: 0 + - number: 42 + - number: 14 + de_pin: + number: 39 + pclk_pin: + number: 45 + hsync_pin: + number: 38 + vsync_pin: + number: 48 + data_rate: 1000000.0 + spi_mode: MODE0 + cs_pin: + number: 21 + show_test_card: true diff --git a/tests/components/mipi_rgb/test.esp32-p4-idf.yaml b/tests/components/mipi_rgb/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..62427492eb --- /dev/null +++ b/tests/components/mipi_rgb/test.esp32-p4-idf.yaml @@ -0,0 +1,6 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-p4-idf.yaml + +psram: + +<<: !include common.yaml diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 642292f7c4..399c25c1d0 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -4,58 +4,4 @@ packages: psram: mode: octal -display: - - platform: mipi_rgb - spi_id: spi_bus - model: ZX2D10GE01R-V4848 - update_interval: 1s - color_order: BGR - draw_rounding: 2 - pixel_mode: 18bit - invert_colors: false - use_axis_flips: true - pclk_frequency: 15000000.0 - pclk_inverted: true - byte_order: big_endian - hsync_pulse_width: 10 - hsync_back_porch: 10 - hsync_front_porch: 10 - vsync_pulse_width: 2 - vsync_back_porch: 12 - vsync_front_porch: 14 - data_pins: - red: - - number: 10 - - number: 16 - - number: 9 - - number: 15 - - number: 46 - ignore_strapping_warning: true - green: - - number: 8 - - number: 13 - - number: 18 - - number: 12 - - number: 11 - - number: 17 - blue: - - number: 47 - - number: 1 - - number: 0 - ignore_strapping_warning: true - - number: 42 - - number: 14 - de_pin: - number: 39 - pclk_pin: - number: 45 - ignore_strapping_warning: true - hsync_pin: - number: 38 - vsync_pin: - number: 48 - data_rate: 1000000.0 - spi_mode: MODE0 - cs_pin: - number: 21 - show_test_card: true +<<: !include common.yaml diff --git a/tests/test_build_components/common/spi/esp32-p4-idf.yaml b/tests/test_build_components/common/spi/esp32-p4-idf.yaml new file mode 100644 index 0000000000..5f232a7e94 --- /dev/null +++ b/tests/test_build_components/common/spi/esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +# Common SPI configuration for ESP32-P4 IDF tests + +substitutions: + clk_pin: GPIO36 + mosi_pin: GPIO32 + miso_pin: GPIO33 + +spi: + - id: spi_bus + clk_pin: ${clk_pin} + mosi_pin: ${mosi_pin} + miso_pin: ${miso_pin} From b6fdd299537529f128b570594b3a3584cf7cc8df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 14:42:40 -0600 Subject: [PATCH 35/37] [voice_assistant] Replace timer unordered_map with vector to eliminate per-tick heap allocation (#13857) --- .../components/voice_assistant/__init__.py | 7 ++- .../voice_assistant/voice_assistant.cpp | 46 ++++++++++--------- .../voice_assistant/voice_assistant.h | 9 ++-- .../voice_assistant/common-idf.yaml | 21 +++++++++ tests/components/voice_assistant/common.yaml | 21 +++++++++ 5 files changed, 77 insertions(+), 27 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d28c786dd8..8b7dcb4f21 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -371,7 +371,12 @@ async def to_code(config): if on_timer_tick := config.get(CONF_ON_TIMER_TICK): await automation.build_automation( var.get_timer_tick_trigger(), - [(cg.std_vector.template(Timer), "timers")], + [ + ( + cg.std_vector.template(Timer).operator("const").operator("ref"), + "timers", + ) + ], on_timer_tick, ) has_timers = True diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 7f5fbe62e1..6267d97480 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -859,35 +859,43 @@ void VoiceAssistant::on_audio(const api::VoiceAssistantAudio &msg) { } void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse &msg) { - Timer timer = { - .id = msg.timer_id, - .name = msg.name, - .total_seconds = msg.total_seconds, - .seconds_left = msg.seconds_left, - .is_active = msg.is_active, - }; - this->timers_[timer.id] = timer; + // Find existing timer or add a new one + auto it = this->timers_.begin(); + for (; it != this->timers_.end(); ++it) { + if (it->id == msg.timer_id) + break; + } + if (it == this->timers_.end()) { + this->timers_.push_back({}); + it = this->timers_.end() - 1; + } + it->id = msg.timer_id; + it->name = msg.name; + it->total_seconds = msg.total_seconds; + it->seconds_left = msg.seconds_left; + it->is_active = msg.is_active; + char timer_buf[Timer::TO_STR_BUFFER_SIZE]; ESP_LOGD(TAG, "Timer Event\n" " Type: %" PRId32 "\n" " %s", - msg.event_type, timer.to_str(timer_buf)); + msg.event_type, it->to_str(timer_buf)); switch (msg.event_type) { case api::enums::VOICE_ASSISTANT_TIMER_STARTED: - this->timer_started_trigger_.trigger(timer); + this->timer_started_trigger_.trigger(*it); break; case api::enums::VOICE_ASSISTANT_TIMER_UPDATED: - this->timer_updated_trigger_.trigger(timer); + this->timer_updated_trigger_.trigger(*it); break; case api::enums::VOICE_ASSISTANT_TIMER_CANCELLED: - this->timer_cancelled_trigger_.trigger(timer); - this->timers_.erase(timer.id); + this->timer_cancelled_trigger_.trigger(*it); + this->timers_.erase(it); break; case api::enums::VOICE_ASSISTANT_TIMER_FINISHED: - this->timer_finished_trigger_.trigger(timer); - this->timers_.erase(timer.id); + this->timer_finished_trigger_.trigger(*it); + this->timers_.erase(it); break; } @@ -901,16 +909,12 @@ void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse } void VoiceAssistant::timer_tick_() { - std::vector res; - res.reserve(this->timers_.size()); - for (auto &pair : this->timers_) { - auto &timer = pair.second; + for (auto &timer : this->timers_) { if (timer.is_active && timer.seconds_left > 0) { timer.seconds_left--; } - res.push_back(timer); } - this->timer_tick_trigger_.trigger(res); + this->timer_tick_trigger_.trigger(this->timers_); } void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg) { diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 0ef7ecc81a..b1b5f20bff 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -24,7 +24,6 @@ #include "esphome/components/socket/socket.h" #include -#include #include namespace esphome { @@ -226,9 +225,9 @@ class VoiceAssistant : public Component { Trigger *get_timer_updated_trigger() { return &this->timer_updated_trigger_; } Trigger *get_timer_cancelled_trigger() { return &this->timer_cancelled_trigger_; } Trigger *get_timer_finished_trigger() { return &this->timer_finished_trigger_; } - Trigger> *get_timer_tick_trigger() { return &this->timer_tick_trigger_; } + Trigger &> *get_timer_tick_trigger() { return &this->timer_tick_trigger_; } void set_has_timers(bool has_timers) { this->has_timers_ = has_timers; } - const std::unordered_map &get_timers() const { return this->timers_; } + const std::vector &get_timers() const { return this->timers_; } protected: bool allocate_buffers_(); @@ -267,13 +266,13 @@ class VoiceAssistant : public Component { api::APIConnection *api_client_{nullptr}; - std::unordered_map timers_; + std::vector timers_; void timer_tick_(); Trigger timer_started_trigger_; Trigger timer_finished_trigger_; Trigger timer_updated_trigger_; Trigger timer_cancelled_trigger_; - Trigger> timer_tick_trigger_; + Trigger &> timer_tick_trigger_; bool has_timers_{false}; bool timer_tick_running_{false}; diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index ab8cbf2434..8565683700 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -68,3 +68,24 @@ voice_assistant: - logger.log: format: "Voice assistant error - code %s, message: %s" args: [code.c_str(), message.c_str()] + on_timer_started: + - logger.log: + format: "Timer started: %s" + args: [timer.id.c_str()] + on_timer_updated: + - logger.log: + format: "Timer updated: %s" + args: [timer.id.c_str()] + on_timer_cancelled: + - logger.log: + format: "Timer cancelled: %s" + args: [timer.id.c_str()] + on_timer_finished: + - logger.log: + format: "Timer finished: %s" + args: [timer.id.c_str()] + on_timer_tick: + - lambda: |- + for (auto &timer : timers) { + ESP_LOGD("timer", "Timer %s: %" PRIu32 "s left", timer.name.c_str(), timer.seconds_left); + } diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index f248154b7e..d09de74396 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -58,3 +58,24 @@ voice_assistant: - logger.log: format: "Voice assistant error - code %s, message: %s" args: [code.c_str(), message.c_str()] + on_timer_started: + - logger.log: + format: "Timer started: %s" + args: [timer.id.c_str()] + on_timer_updated: + - logger.log: + format: "Timer updated: %s" + args: [timer.id.c_str()] + on_timer_cancelled: + - logger.log: + format: "Timer cancelled: %s" + args: [timer.id.c_str()] + on_timer_finished: + - logger.log: + format: "Timer finished: %s" + args: [timer.id.c_str()] + on_timer_tick: + - lambda: |- + for (auto &timer : timers) { + ESP_LOGD("timer", "Timer %s: %" PRIu32 "s left", timer.name.c_str(), timer.seconds_left); + } From dbf202bf0de5beb0cd09661d424961b810f2db08 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 9 Feb 2026 12:57:36 -0800 Subject: [PATCH 36/37] Add get_away and get_on in WaterHeaterCall and deprecate get_state (#13891) --- esphome/components/water_heater/water_heater.h | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 93fcf5f401..070ae99575 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -90,9 +90,22 @@ class WaterHeaterCall { float get_target_temperature_low() const { return this->target_temperature_low_; } float get_target_temperature_high() const { return this->target_temperature_high_; } /// Get state flags value + ESPDEPRECATED("get_state() is deprecated, use get_away() and get_on() instead. (Removed in 2026.8.0)", "2026.2.0") uint32_t get_state() const { return this->state_; } - /// Get mask of state flags that are being changed - uint32_t get_state_mask() const { return this->state_mask_; } + + optional get_away() const { + if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { + return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; + } + return {}; + } + + optional get_on() const { + if (this->state_mask_ & WATER_HEATER_STATE_ON) { + return (this->state_ & WATER_HEATER_STATE_ON) != 0; + } + return {}; + } protected: void validate_(); From b2b9e0cb0ae69b9d9415790e637172996a2cb2ef Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 9 Feb 2026 22:00:08 +0100 Subject: [PATCH 37/37] [nrf52,zigee] print reporting status (#13890) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/zigbee/zigbee_zephyr.cpp | 29 +++++++++++++++++++++ esphome/components/zigbee/zigbee_zephyr.h | 1 + 2 files changed, 30 insertions(+) diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 4763943e88..65639de61b 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include #include +#include "esphome/core/hal.h" extern "C" { #include @@ -223,6 +224,7 @@ void ZigbeeComponent::dump_config() { get_wipe_on_boot(), YESNO(zb_zdo_joined()), zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id()); + dump_reporting_(); } static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { @@ -244,6 +246,33 @@ void ZigbeeComponent::factory_reset() { ZB_SCHEDULE_APP_CALLBACK(zb_bdb_reset_via_local_action, 0); } +void ZigbeeComponent::dump_reporting_() { +#ifdef ESPHOME_LOG_HAS_VERBOSE + auto now = millis(); + bool first = true; + for (zb_uint8_t j = 0; j < ZCL_CTX().device_ctx->ep_count; j++) { + if (ZCL_CTX().device_ctx->ep_desc_list[j]->reporting_info) { + zb_zcl_reporting_info_t *rep_info = ZCL_CTX().device_ctx->ep_desc_list[j]->reporting_info; + for (zb_uint8_t i = 0; i < ZCL_CTX().device_ctx->ep_desc_list[j]->rep_info_count; i++) { + if (!first) { + ESP_LOGV(TAG, ""); + } + first = false; + ESP_LOGV(TAG, "Endpoint: %d, cluster_id %d, attr_id %d, flags %d, report in %ums", rep_info->ep, + rep_info->cluster_id, rep_info->attr_id, rep_info->flags, + ZB_ZCL_GET_REPORTING_FLAG(rep_info, ZB_ZCL_REPORT_TIMER_STARTED) + ? ZB_TIME_BEACON_INTERVAL_TO_MSEC(rep_info->run_time) - now + : 0); + ESP_LOGV(TAG, "Min_interval %ds, max_interval %ds, def_min_interval %ds, def_max_interval %ds", + rep_info->u.send_info.min_interval, rep_info->u.send_info.max_interval, + rep_info->u.send_info.def_min_interval, rep_info->u.send_info.def_max_interval); + rep_info++; + } + } + } +#endif +} + } // namespace esphome::zigbee extern "C" void zboss_signal_handler(zb_uint8_t param) { diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 05895e8e61..bd4b092ad5 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -87,6 +87,7 @@ class ZigbeeComponent : public Component { #ifdef USE_ZIGBEE_WIPE_ON_BOOT void erase_flash_(int area); #endif + void dump_reporting_(); std::array, ZIGBEE_ENDPOINTS_COUNT> callbacks_{}; CallbackManager join_cb_; Trigger<> join_trigger_;