From 4b50d14496896181d261a3c8a9dc5b4d4a062bda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2026 21:10:03 -1000 Subject: [PATCH 01/44] [serial_proxy] Reduce loop() overhead by disabling when idle and splitting read path (#14673) --- .../components/serial_proxy/serial_proxy.cpp | 29 ++++++++++++++----- .../components/serial_proxy/serial_proxy.h | 5 ++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 340f9b0cb85..00d822b75cb 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -28,25 +28,38 @@ void SerialProxy::setup() { // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; #endif + // No subscriber at startup; disable loop until a client subscribes + this->disable_loop(); } void SerialProxy::loop() { #ifdef USE_API - // Detect subscriber disconnect - if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || - !this->api_connection_->is_connection_setup() || !api_is_connected())) { - ESP_LOGW(TAG, "Subscriber disconnected"); - this->api_connection_ = nullptr; + // Safety check — loop should only run when subscribed, but guard against races + if (this->api_connection_ == nullptr) [[unlikely]] { + this->disable_loop(); + return; } - if (this->api_connection_ == nullptr) + // Detect subscriber disconnect + if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || + !api_is_connected()) { + ESP_LOGW(TAG, "Subscriber disconnected"); + this->api_connection_ = nullptr; + this->disable_loop(); return; + } // Read available data from UART and forward to subscribed client size_t available = this->available(); if (available == 0) return; + this->read_and_send_(available); +#endif +} + +#ifdef USE_API +void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { // Read in chunks up to SERIAL_PROXY_MAX_READ_SIZE uint8_t buffer[SERIAL_PROXY_MAX_READ_SIZE]; size_t to_read = std::min(available, sizeof(buffer)); @@ -56,8 +69,8 @@ void SerialProxy::loop() { this->outgoing_msg_.set_data(buffer, to_read); this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); -#endif } +#endif void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, @@ -166,6 +179,7 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: return; } this->api_connection_ = api_connection; + this->enable_loop(); ESP_LOGV(TAG, "API connection subscribed to serial proxy [%u]", this->instance_index_); break; case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE: @@ -174,6 +188,7 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: return; } this->api_connection_ = nullptr; + this->disable_loop(); ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%u]", this->instance_index_); break; default: diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 62f942b19d8..5adfa4fe53b 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -101,6 +101,11 @@ class SerialProxy : public uart::UARTDevice, public Component { void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } protected: +#ifdef USE_API + /// Read from UART and send to API client (slow path with 256-byte stack buffer) + void read_and_send_(size_t available); +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; From fba21e6dd4bd321d4b0ee3b31869e81fee75aab5 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Tue, 10 Mar 2026 20:14:19 +0530 Subject: [PATCH 02/44] [bl0940] Fix reset_calibration() declaration missing from header (#14676) Co-authored-by: Claude --- esphome/components/bl0940/bl0940.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 93d54003f5f..e0ca748a222 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -69,10 +69,8 @@ class BL0940 : public PollingComponent, public uart::UARTDevice { void set_energy_calibration_number(number::Number *num) { this->energy_calibration_number_ = num; } #endif -#ifdef USE_BUTTON - // Resets all calibration values to defaults (can be triggered by a button) + // Resets all calibration values to defaults void reset_calibration(); -#endif // Core component methods void loop() override; From 06a127f64b49c7306815100cc667af8c9842c463 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Tue, 10 Mar 2026 16:52:48 +0100 Subject: [PATCH 03/44] [core] ESP-IDF compilation fixes (#14541) --- esphome/build_gen/espidf.py | 6 ++-- esphome/espidf_api.py | 61 ++++++++++++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index f45efb82c1d..9df9b1069c5 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -72,7 +72,7 @@ def get_component_cmakelists(minimal: bool = False) -> str: # Extract compile definitions from build flags (-DXXX -> XXX) compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")] - compile_defs_str = "\n ".join(compile_defs) if compile_defs else "" + compile_defs_str = "\n ".join(sorted(compile_defs)) if compile_defs else "" # Extract compile options (-W flags, excluding linker flags) compile_opts = [ @@ -80,11 +80,11 @@ def get_component_cmakelists(minimal: bool = False) -> str: for flag in CORE.build_flags if flag.startswith("-W") and not flag.startswith("-Wl,") ] - compile_opts_str = "\n ".join(compile_opts) if compile_opts else "" + compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else "" # Extract linker options (-Wl, flags) link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(link_opts) if link_opts else "" + link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" return f"""\ # Auto-generated by ESPHome diff --git a/esphome/espidf_api.py b/esphome/espidf_api.py index 9e9c57bfbdb..9ebcc48513c 100644 --- a/esphome/espidf_api.py +++ b/esphome/espidf_api.py @@ -8,7 +8,6 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE -from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) @@ -102,6 +101,55 @@ def run_reconfigure() -> int: return run_idf_py("reconfigure") +def has_outdated_files(): + """Check if the build configuration is stale. + + Returns True if required build files are missing or if configuration inputs + are newer than the generated CMake/Ninja build artifacts. + """ + cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") + + cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") + cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") + build_config_path = CORE.relative_build_path("build/config") + sdkconfig_internal_path = CORE.relative_build_path( + f"sdkconfig.{CORE.name}.esphomeinternal" + ) + dependency_lock_path = CORE.relative_build_path("dependencies.lock") + build_ninja_path = CORE.relative_build_path("build/build.ninja") + + if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): + return True + if not os.path.isfile(cmakecache_txt_path): + return True + if not os.path.isfile(build_ninja_path): + return True + if os.path.isfile(dependency_lock_path) and os.path.getmtime( + dependency_lock_path + ) > os.path.getmtime(build_ninja_path): + return True + + cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) + return any( + os.path.getmtime(f) > cmakecache_txt_mtime + for f in [ + _get_idf_path(), + cmakelists_txt_build_path, + cmakelists_txt_src_path, + sdkconfig_internal_path, + build_config_path, + ] + if f and os.path.exists(f) + ) + + +def need_reconfigure() -> bool: + from esphome.build_gen.espidf import has_discovered_components + + # We need to reconfigure either if the files are outdated or if there is no component discovered + return has_outdated_files() or not has_discovered_components() + + def run_compile(config, verbose: bool) -> int: """Compile the ESP-IDF project. @@ -110,10 +158,10 @@ def run_compile(config, verbose: bool) -> int: 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ - from esphome.build_gen.espidf import has_discovered_components, write_project + from esphome.build_gen.espidf import write_project # Check if we need to do discovery phase - if not has_discovered_components(): + if need_reconfigure(): _LOGGER.info("Discovering available ESP-IDF components...") write_project(minimal=True) rc = run_reconfigure() @@ -124,15 +172,12 @@ def run_compile(config, verbose: bool) -> int: write_project(minimal=False) # Build - args = ["build"] + args = [] if verbose: args.append("-v") - # Add parallel job limit if configured - if CONF_COMPILE_PROCESS_LIMIT in config.get(CONF_ESPHOME, {}): - limit = config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT] - args.extend(["-j", str(limit)]) + args.append("build") # Set the sdkconfig file sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") From 2c7ef4f758522ac324a5983dfc89f963fa22af70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:10:33 -1000 Subject: [PATCH 04/44] [rp2040] Use picotool for BOOTSEL upload and improve upload UX (#14483) --- esphome/__main__.py | 211 ++++++++++++++++- esphome/espota2.py | 26 +-- esphome/helpers.py | 27 +++ esphome/util.py | 70 ++++++ tests/unit_tests/test_main.py | 420 ++++++++++++++++++++++++++++++++++ tests/unit_tests/test_util.py | 132 +++++++++++ 6 files changed, 859 insertions(+), 27 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0164e2eeb33..f33e7f4b426 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -9,6 +9,8 @@ import logging import os from pathlib import Path import re +import shutil +import subprocess import sys import time from typing import Protocol @@ -44,7 +46,9 @@ from esphome.const import ( CONF_SUBSTITUTIONS, CONF_TOPIC, ENV_NOGITIGNORE, + KEY_CORE, KEY_NATIVE_IDF, + KEY_TARGET_PLATFORM, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, @@ -56,7 +60,11 @@ from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType from esphome.util import ( + PICOTOOL_PACKAGE, + detect_rp2040_bootsel, + get_picotool_path, get_serial_ports, + is_picotool_usb_permission_error, list_yaml_files, run_external_command, run_external_process, @@ -68,6 +76,21 @@ _LOGGER = logging.getLogger(__name__) # Maximum buffer size for serial log reading to prevent unbounded memory growth SERIAL_BUFFER_MAX_SIZE = 65536 +_RP2040_BOOTSEL_INSTRUCTIONS = ( + "To enter BOOTSEL mode:\n" + " 1. Unplug the device\n" + " 2. Hold the BOOT/BOOTSEL button\n" + " 3. Plug in the USB cable while holding the button\n" + " 4. Release the button - the device should appear as a USB drive (RPI-RP2)\n" + "Then run the upload command again." +) + +_RP2040_UDEV_HINT = ( + "You may need to add a udev rule for RP2040 devices. " + "See: https://github.com/raspberrypi/picotool" + "/blob/master/udev/60-picotool.rules" +) + # Special non-component keys that appear in configs _NON_COMPONENT_KEYS = frozenset( { @@ -163,6 +186,7 @@ class PortType(StrEnum): NETWORK = "NETWORK" MQTT = "MQTT" MQTTIP = "MQTTIP" + BOOTSEL = "BOOTSEL" # Magic MQTT port types that require special handling @@ -241,6 +265,19 @@ def choose_upload_log_host( (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] + # Add RP2040 BOOTSEL device option when uploading + bootsel_permission_error = False + if ( + purpose == Purpose.UPLOADING + and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and (picotool := _find_picotool()) is not None + ): + bootsel = detect_rp2040_bootsel(picotool) + if bootsel.device_count > 0: + options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL")) + elif bootsel.permission_error: + bootsel_permission_error = True + if purpose == Purpose.LOGGING: if has_mqtt_logging(): mqtt_config = CORE.config[CONF_MQTT] @@ -258,6 +295,25 @@ def choose_upload_log_host( if has_mqtt_ip_lookup(): options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found + if ( + purpose == Purpose.UPLOADING + and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) + ): + if bootsel_permission_error: + _LOGGER.warning( + "An RP2040 device in BOOTSEL mode was detected but could " + "not be accessed due to USB permissions." + ) + if sys.platform.startswith("linux"): + _LOGGER.warning(_RP2040_UDEV_HINT) + if not options: + raise EsphomeError( + f"No RP2040 device found. {_RP2040_BOOTSEL_INSTRUCTIONS}" + ) + _LOGGER.info("Tip: %s", _RP2040_BOOTSEL_INSTRUCTIONS) + if check_default is not None and check_default in [opt[1] for opt in options]: return [check_default] return [choose_prompt(options, purpose=purpose)] @@ -404,10 +460,13 @@ def get_port_type(port: str) -> PortType: Returns: PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.) + PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool PortType.MQTT for MQTT logging PortType.MQTTIP for MQTT IP lookup PortType.NETWORK for IP addresses, hostnames, or mDNS names """ + if port == "BOOTSEL": + return PortType.BOOTSEL if port.startswith("/") or port.startswith("COM"): return PortType.SERIAL if port == "MQTT": @@ -695,15 +754,138 @@ def upload_using_esptool( return run_esptool(115200) -def upload_using_platformio(config: ConfigType, port: str): +def upload_using_platformio(config: ConfigType, port: str) -> int: from esphome import platformio_api + # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for + # the upload target, but 'nobuild' skips the build phase that creates it. + # Create it here so the upload doesn't fail. + if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: + idedata = platformio_api.get_idedata(config) + build_dir = Path(idedata.firmware_elf_path).parent + firmware_bin = build_dir / "firmware.bin" + signed_bin = build_dir / "firmware.bin.signed" + if firmware_bin.is_file() and not signed_bin.is_file(): + shutil.copy2(firmware_bin, signed_bin) + upload_args = ["-t", "upload", "-t", "nobuild"] if port is not None: upload_args += ["--upload-port", port] return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) +def _find_picotool() -> Path | None: + """Find the picotool binary from PlatformIO packages.""" + from esphome import platformio_api + + try: + idedata = platformio_api.get_idedata(CORE.config) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + return None + return get_picotool_path(idedata.cc_path) + + +def upload_using_picotool(config: ConfigType) -> int: + """Upload firmware to RP2040 in BOOTSEL mode using picotool. + + Uses picotool to load the ELF firmware directly via USB, avoiding + the mass storage copy approach that causes "disk not ejected properly" + warnings on macOS. + """ + from esphome import platformio_api + + idedata = platformio_api.get_idedata(config) + firmware_elf = Path(idedata.firmware_elf_path) + + if not firmware_elf.is_file(): + _LOGGER.error( + "Firmware ELF file not found at %s. " + "Make sure the project has been compiled first.", + firmware_elf, + ) + return 1 + + picotool = get_picotool_path(idedata.cc_path) + if picotool is None: + _LOGGER.error( + "picotool not found. Ensure the RP2040 PlatformIO platform " + "is installed (%s).", + PICOTOOL_PACKAGE, + ) + return 1 + + _LOGGER.info("Uploading firmware to RP2040 via picotool...") + try: + # Don't capture stdout — let picotool write directly to the terminal + # so progress bars display in real-time with \r updates. + # Capture stderr only so we can detect permission errors. + result = subprocess.run( + [str(picotool), "load", "-v", "-x", str(firmware_elf)], + stderr=subprocess.PIPE, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired: + _LOGGER.error("picotool upload timed out after 60 seconds.") + return 1 + except OSError as err: + _LOGGER.error("Failed to run picotool: %s", err) + return 1 + + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="replace").strip() + if stderr: + for line in stderr.splitlines(): + safe_print(line) + if is_picotool_usb_permission_error(stderr): + msg = "Permission denied accessing USB device." + if sys.platform.startswith("linux"): + msg += f" {_RP2040_UDEV_HINT}" + _LOGGER.error(msg) + else: + _LOGGER.error("picotool upload failed (exit code %d).", result.returncode) + return 1 + + return 0 + + +def _wait_for_serial_port( + port: str | None = None, + timeout: float = 30.0, + known_ports: set[str] | None = None, +) -> None: + """Wait for a serial port to appear, e.g. after a device reboot. + + USB-CDC devices disappear briefly after flashing while the device + reboots and re-enumerates on the USB bus. + + If port is given, wait for that specific path. If known_ports is + given, wait for a new port that wasn't in the set. Otherwise wait + for any serial port to appear. + """ + + def _port_found() -> bool: + ports = get_serial_ports() + if port is not None: + return any(p.path == port for p in ports) + if known_ports is not None: + return any(p.path not in known_ports for p in ports) + return bool(ports) + + if _port_found(): + return + if port is not None: + _LOGGER.info("Waiting for %s to come online...", port) + else: + _LOGGER.info("Waiting for device to reboot...") + start = time.monotonic() + while time.monotonic() - start < timeout: + time.sleep(0.05) + if _port_found(): + time.sleep(0.05) + return + + def check_permissions(port: str): if os.name == "posix" and get_port_type(port) == PortType.SERIAL: # Check if we can open selected serial port @@ -733,7 +915,15 @@ def upload_program( except AttributeError: pass - if get_port_type(host) == PortType.SERIAL: + port_type = get_port_type(host) + + if port_type == PortType.BOOTSEL: + exit_code = upload_using_picotool(config) + # Return None for device - BOOTSEL can't be used for logging, + # so command_run will show the interactive chooser for log source + return exit_code, None + + if port_type == PortType.SERIAL: check_permissions(host) exit_code = 1 @@ -787,6 +977,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int port_type = get_port_type(port) if port_type == PortType.SERIAL: + _wait_for_serial_port(port) check_permissions(port) return run_miniterm(config, port, args) @@ -925,6 +1116,9 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: purpose=Purpose.UPLOADING, ) + # Snapshot current serial ports before upload so we can detect new ones + pre_upload_ports = {p.path for p in get_serial_ports()} + exit_code, successful_device = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") @@ -935,6 +1129,19 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: if args.no_logs: return 0 + # After BOOTSEL upload, wait for a new serial port to appear + # so it shows up in the log chooser + if ( + successful_device is None + and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + ): + _wait_for_serial_port(known_ports=pre_upload_ports) + # If exactly one new serial port appeared, use it directly + serial_ports = get_serial_ports() + new_ports = [p for p in serial_ports if p.path not in pre_upload_ports] + if len(new_ports) == 1: + successful_device = new_ports[0].path + # For logs, prefer the device we successfully uploaded to devices = choose_upload_log_host( default=successful_device, diff --git a/esphome/espota2.py b/esphome/espota2.py index c342eb4463c..c412bb51ffd 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -13,7 +13,7 @@ import time from typing import Any from esphome.core import EsphomeError -from esphome.helpers import resolve_ip_address +from esphome.helpers import ProgressBar, resolve_ip_address RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 @@ -63,30 +63,6 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { } -class ProgressBar: - def __init__(self): - self.last_progress = None - - def update(self, progress): - bar_length = 60 - status = "" - if progress >= 1: - progress = 1 - status = "Done...\r\n" - new_progress = int(progress * 100) - if new_progress == self.last_progress: - return - self.last_progress = new_progress - block = int(round(bar_length * progress)) - text = f"\rUploading: [{'=' * block + ' ' * (bar_length - block)}] {new_progress}% {status}" - sys.stderr.write(text) - sys.stderr.flush() - - def done(self): - sys.stderr.write("\n") - sys.stderr.flush() - - class OTAError(EsphomeError): pass diff --git a/esphome/helpers.py b/esphome/helpers.py index 145ebd40968..f41bec357d6 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -9,6 +9,7 @@ import platform import re import shutil import stat +import sys import tempfile from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -585,6 +586,32 @@ def sanitize(value): return _DISALLOWED_CHARS.sub("_", value) +class ProgressBar: + """A simple terminal progress bar for upload operations.""" + + def __init__(self) -> None: + self.last_progress: int | None = None + + def update(self, progress: float) -> None: + bar_length = 60 + status = "" + if progress >= 1: + progress = 1 + status = "Done...\r\n" + new_progress = int(progress * 100) + if new_progress == self.last_progress: + return + self.last_progress = new_progress + block = int(round(bar_length * progress)) + text = f"\rUploading: [{'=' * block + ' ' * (bar_length - block)}] {new_progress}% {status}" + sys.stderr.write(text) + sys.stderr.flush() + + def done(self) -> None: + sys.stderr.write("\n") + sys.stderr.flush() + + def docs_url(path: str) -> str: """Return the URL to the documentation for a given path.""" # Local import to avoid circular import diff --git a/esphome/util.py b/esphome/util.py index 686aa74306a..6a21b4f627f 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,5 +1,6 @@ import collections from collections.abc import Callable +from dataclasses import dataclass import io import logging from pathlib import Path @@ -355,6 +356,75 @@ def get_serial_ports() -> list[SerialPort]: return result +PICOTOOL_PACKAGE = "tool-picotool-rp2040-earlephilhower" + + +def get_picotool_path(cc_path: str) -> Path | None: + """Derive the picotool binary path from the PlatformIO toolchain cc_path. + + The cc_path from IDEData points to the toolchain package, e.g.: + ~/.platformio/packages/toolchain-rp2040-earlephilhower/bin/arm-none-eabi-gcc + Picotool is in a sibling package: + ~/.platformio/packages/tool-picotool-rp2040-earlephilhower/picotool + """ + cc = Path(cc_path) + # Go from .../packages/toolchain-.../bin/gcc up to .../packages/ + packages_dir = cc.parent.parent.parent + binary_name = "picotool.exe" if sys.platform == "win32" else "picotool" + picotool = packages_dir / PICOTOOL_PACKAGE / binary_name + if picotool.is_file(): + return picotool + return None + + +def is_picotool_usb_permission_error(output: str | bytes) -> bool: + """Check if picotool output indicates a USB permission error.""" + if isinstance(output, str): + return ( + "unable to connect" in output + or "LIBUSB_ERROR_ACCESS" in output + or "Permission denied" in output + ) + return ( + b"unable to connect" in output + or b"LIBUSB_ERROR_ACCESS" in output + or b"Permission denied" in output + ) + + +@dataclass +class BootselResult: + """Result of RP2040 BOOTSEL detection.""" + + device_count: int + permission_error: bool = False + + +def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: + """Detect RP2040/RP2350 devices in BOOTSEL mode using picotool. + + Returns a BootselResult with the number of devices found (by counting + 'type:' lines in output), and whether a permission error was detected. + """ + try: + result = subprocess.run( + [str(picotool_path), "info", "-d"], + capture_output=True, + timeout=10, + check=False, + ) + device_count = result.stdout.count(b"type:") + if device_count > 0: + return BootselResult(device_count) + # Check for permission issues — picotool can see the device + # on the USB bus but can't connect without proper permissions + if is_picotool_usb_permission_error(result.stderr + result.stdout): + return BootselResult(0, permission_error=True) + return BootselResult(0) + except (OSError, subprocess.TimeoutExpired): + return BootselResult(0) + + def get_esp32_arduino_flash_error_help() -> str | None: """Returns helpful message when ESP32 with Arduino runs out of flash space.""" from esphome.core import CORE diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index cef561c54b7..b6f1a28086a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -8,6 +8,7 @@ import json import logging from pathlib import Path import re +import sys import time from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -40,6 +41,8 @@ from esphome.__main__ import ( show_logs, upload_program, upload_using_esptool, + upload_using_picotool, + upload_using_platformio, ) from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( @@ -70,6 +73,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.util import BootselResult def strip_ansi_codes(text: str) -> str: @@ -174,6 +178,13 @@ def mock_upload_using_platformio() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_upload_using_picotool() -> Generator[Mock]: + """Mock upload_using_picotool for testing.""" + with patch("esphome.__main__.upload_using_picotool") as mock: + yield mock + + @pytest.fixture def mock_run_ota() -> Generator[Mock]: """Mock espota2.run_ota for testing.""" @@ -851,6 +862,221 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: ) +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode shows RP2040 BOOTSEL option via picotool.""" + setup_core(platform=PLATFORM_RP2040) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)), + ): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["/dev/ttyUSB0"] # mock_choose_prompt default + mock_choose_prompt.assert_called_once_with( + [("RP2040 BOOTSEL (via picotool)", "BOOTSEL")], + purpose=Purpose.UPLOADING, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: + """Test BOOTSEL instructions shown when no RP2040 device found.""" + setup_core(platform=PLATFORM_RP2040) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), + pytest.raises(EsphomeError, match="BOOTSEL"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test BOOTSEL tip shown when only OTA options exist for RP2040.""" + setup_core( + platform=PLATFORM_RP2040, + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + ) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), + patch( + "esphome.__main__.choose_prompt", + return_value="192.168.1.100", + ), + caplog.at_level(logging.INFO, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert "BOOTSEL" in caplog.text + + +def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( + caplog: pytest.LogCaptureFixture, + mock_choose_prompt: Mock, +) -> None: + """Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device.""" + setup_core(platform=PLATFORM_RP2040) + + mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] + with ( + patch("esphome.__main__.get_serial_ports", return_value=mock_ports), + patch( + "esphome.__main__._find_picotool", + return_value=Path("/usr/bin/picotool"), + ), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), + caplog.at_level(logging.INFO, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert "BOOTSEL" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_permission_error_no_options( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test permission warning shown when BOOTSEL device found but not accessible.""" + setup_core(platform=PLATFORM_RP2040) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch( + "esphome.__main__.detect_rp2040_bootsel", + return_value=BootselResult(0, permission_error=True), + ), + patch("esphome.__main__.sys.platform", "linux"), + pytest.raises(EsphomeError, match="BOOTSEL"), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "USB permissions" in caplog.text + assert "udev" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_permission_error_with_ota( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test permission warning shown with OTA fallback available.""" + setup_core( + platform=PLATFORM_RP2040, + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + ) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch( + "esphome.__main__.detect_rp2040_bootsel", + return_value=BootselResult(0, permission_error=True), + ), + patch( + "esphome.__main__.choose_prompt", + return_value="192.168.1.100", + ), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "USB permissions" in caplog.text + + +def test_choose_upload_log_host_no_bootsel_for_non_rp2040( + mock_no_serial_ports: Mock, +) -> None: + """Test that BOOTSEL detection is not run for non-RP2040 platforms.""" + setup_core( + platform=PLATFORM_ESP32, + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + ) + + with ( + patch("esphome.__main__._find_picotool") as mock_find_picotool, + patch( + "esphome.__main__.choose_prompt", + return_value="192.168.1.100", + ), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + mock_find_picotool.assert_not_called() + + +def test_choose_upload_log_host_rp2040_serial_and_bootsel( + mock_choose_prompt: Mock, +) -> None: + """Test both serial ports and BOOTSEL option shown for RP2040.""" + setup_core(platform=PLATFORM_RP2040) + + mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] + with ( + patch("esphome.__main__.get_serial_ports", return_value=mock_ports), + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + mock_choose_prompt.assert_called_once_with( + [ + ("/dev/ttyACM0 (RP2040 Serial)", "/dev/ttyACM0"), + ("RP2040 BOOTSEL (via picotool)", "BOOTSEL"), + ], + purpose=Purpose.UPLOADING, + ) + + @dataclass class MockArgs: """Mock args for testing.""" @@ -1060,6 +1286,46 @@ def test_upload_program_serial_platformio_platforms( mock_upload_using_platformio.assert_called_once_with(config, device) +def test_upload_using_platformio_creates_signed_bin_for_rp2040( + tmp_path: Path, +) -> None: + """Test that upload_using_platformio creates firmware.bin.signed for RP2040.""" + setup_core(platform=PLATFORM_RP2040) + + build_dir = tmp_path / "build" + build_dir.mkdir() + firmware_bin = build_dir / "firmware.bin" + firmware_bin.write_bytes(b"test firmware content") + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"elf") + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + + with ( + patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("esphome.platformio_api.run_platformio_cli_run", return_value=0), + ): + result = upload_using_platformio({}, "/dev/ttyACM0") + + assert result == 0 + signed_bin = build_dir / "firmware.bin.signed" + assert signed_bin.is_file() + assert signed_bin.read_bytes() == b"test firmware content" + + +def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( + tmp_path: Path, +) -> None: + """Test that upload_using_platformio doesn't create signed bin for non-RP2040.""" + setup_core(platform=PLATFORM_ESP32) + + with patch("esphome.platformio_api.run_platformio_cli_run", return_value=0): + result = upload_using_platformio({}, "/dev/ttyUSB0") + + assert result == 0 + + def test_upload_program_serial_upload_failed( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -1082,6 +1348,158 @@ def test_upload_program_serial_upload_failed( mock_upload_using_esptool.assert_called_once() +def test_upload_program_bootsel( + mock_upload_using_picotool: Mock, + mock_get_port_type: Mock, +) -> None: + """Test upload_program with BOOTSEL for RP2040.""" + setup_core(platform=PLATFORM_RP2040) + mock_get_port_type.return_value = "BOOTSEL" + mock_upload_using_picotool.return_value = 0 + + config = {} + args = MockArgs() + devices = ["BOOTSEL"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + # BOOTSEL device can't be used for logging, so host should be None + assert host is None + mock_upload_using_picotool.assert_called_once_with(config) + + +def test_upload_program_bootsel_failed( + mock_upload_using_picotool: Mock, + mock_get_port_type: Mock, +) -> None: + """Test upload_program when BOOTSEL upload fails.""" + setup_core(platform=PLATFORM_RP2040) + mock_get_port_type.return_value = "BOOTSEL" + mock_upload_using_picotool.return_value = 1 + + config = {} + args = MockArgs() + devices = ["BOOTSEL"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_upload_using_picotool.assert_called_once_with(config) + + +def test_upload_using_picotool_success(tmp_path: Path) -> None: + """Test upload_using_picotool succeeds.""" + setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + + build_dir = tmp_path / "build" + build_dir.mkdir() + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"\x00" * 1024) + + # Create picotool binary + packages_dir = tmp_path / "packages" + toolchain_bin = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_bin.mkdir(parents=True) + picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" + picotool_dir.mkdir(parents=True) + binary_name = "picotool.exe" if sys.platform == "win32" else "picotool" + picotool = picotool_dir / binary_name + picotool.touch() + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + mock_idedata.cc_path = str(toolchain_bin / "arm-none-eabi-gcc") + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = b"" + + config = {} + with ( + patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("subprocess.run", return_value=mock_result), + ): + exit_code = upload_using_picotool(config) + + assert exit_code == 0 + + +def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: + """Test upload_using_picotool when ELF file is missing.""" + setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + + build_dir = tmp_path / "build" + build_dir.mkdir() + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(build_dir / "firmware.elf") + mock_idedata.cc_path = "/fake/path/gcc" + + config = {} + with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata): + exit_code = upload_using_picotool(config) + + assert exit_code == 1 + + +def test_upload_using_picotool_not_found(tmp_path: Path) -> None: + """Test upload_using_picotool when picotool binary not found.""" + setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + + build_dir = tmp_path / "build" + build_dir.mkdir() + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"\x00" * 512) + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + mock_idedata.cc_path = "/fake/path/gcc" + + config = {} + with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata): + exit_code = upload_using_picotool(config) + + assert exit_code == 1 + + +def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: + """Test upload_using_picotool shows helpful message on permission error.""" + setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + + build_dir = tmp_path / "build" + build_dir.mkdir() + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"\x00" * 512) + + packages_dir = tmp_path / "packages" + toolchain_bin = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_bin.mkdir(parents=True) + picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" + picotool_dir.mkdir(parents=True) + binary_name = "picotool.exe" if sys.platform == "win32" else "picotool" + picotool = picotool_dir / binary_name + picotool.touch() + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + mock_idedata.cc_path = str(toolchain_bin / "arm-none-eabi-gcc") + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = b"LIBUSB_ERROR_ACCESS" + + config = {} + with ( + patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("subprocess.run", return_value=mock_result), + ): + exit_code = upload_using_picotool(config) + + assert exit_code == 1 + + def test_upload_program_ota_success( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -1606,6 +2024,8 @@ def test_get_port_type() -> None: assert get_port_type("esphome-device.local") == "NETWORK" assert get_port_type("10.0.0.1") == "NETWORK" + assert get_port_type("BOOTSEL") == "BOOTSEL" + def test_has_mqtt_ip_lookup() -> None: """Test has_mqtt_ip_lookup function.""" diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 85873caea81..ca3fd9b78ab 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -3,6 +3,9 @@ from __future__ import annotations from pathlib import Path +import subprocess +import sys +from unittest.mock import MagicMock, patch import pytest @@ -402,3 +405,132 @@ def test_shlex_quote_edge_cases() -> None: assert util.shlex_quote("\t") == "'\t'" assert util.shlex_quote("\n") == "'\n'" assert util.shlex_quote(" ") == "' '" + + +def test_get_picotool_path_found(tmp_path: Path) -> None: + """Test picotool path derivation from cc_path.""" + # Create the expected directory structure + packages_dir = tmp_path / "packages" + toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_dir.mkdir(parents=True) + gcc = toolchain_dir / "arm-none-eabi-gcc" + gcc.touch() + + binary_name = "picotool.exe" if sys.platform == "win32" else "picotool" + picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" + picotool_dir.mkdir(parents=True) + picotool = picotool_dir / binary_name + picotool.touch() + + result = util.get_picotool_path(str(gcc)) + assert result == picotool + + +def test_get_picotool_path_not_found(tmp_path: Path) -> None: + """Test picotool path returns None when not installed.""" + packages_dir = tmp_path / "packages" + toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_dir.mkdir(parents=True) + gcc = toolchain_dir / "arm-none-eabi-gcc" + gcc.touch() + + result = util.get_picotool_path(str(gcc)) + assert result is None + + +def test_get_picotool_path_windows(tmp_path: Path) -> None: + """Test picotool path uses .exe on Windows.""" + packages_dir = tmp_path / "packages" + toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_dir.mkdir(parents=True) + gcc = toolchain_dir / "arm-none-eabi-gcc.exe" + gcc.touch() + + picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" + picotool_dir.mkdir(parents=True) + picotool = picotool_dir / "picotool.exe" + picotool.touch() + + with patch("esphome.util.sys.platform", "win32"): + result = util.get_picotool_path(str(gcc)) + assert result == picotool + + +def test_detect_rp2040_bootsel_found() -> None: + """Test BOOTSEL device detection when device is present.""" + mock_result = MagicMock() + mock_result.stdout = b"Device Information\n type: RP2040\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 1 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_multiple() -> None: + """Test BOOTSEL detection with multiple devices.""" + mock_result = MagicMock() + mock_result.stdout = b"type: RP2040\ntype: RP2350\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 2 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_none() -> None: + """Test BOOTSEL detection when no device found.""" + mock_result = MagicMock() + mock_result.stdout = ( + b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n" + ) + mock_result.stderr = b"" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_permission_error() -> None: + """Test BOOTSEL detection with device found but not accessible.""" + mock_result = MagicMock() + mock_result.stdout = ( + b"No accessible RP-series devices in BOOTSEL mode were found.\n" + ) + mock_result.stderr = ( + b"RP2040 device at bus 5, address 24 appears to be in BOOTSEL mode, " + b"but picotool was unable to connect. " + b"Maybe try 'sudo' or check your permissions.\n" + ) + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is True + + +def test_detect_rp2040_bootsel_libusb_access_error() -> None: + """Test BOOTSEL detection with LIBUSB_ERROR_ACCESS.""" + mock_result = MagicMock() + mock_result.stdout = b"" + mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is True + + +def test_detect_rp2040_bootsel_oserror() -> None: + """Test BOOTSEL detection handles OSError.""" + with patch("esphome.util.subprocess.run", side_effect=OSError("not found")): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_timeout() -> None: + """Test BOOTSEL detection handles timeout.""" + with patch( + "esphome.util.subprocess.run", + side_effect=subprocess.TimeoutExpired("picotool", 10), + ): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False From 6e468936ec670461e244aa93b7d3ea74f1fa2bef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:10:55 -1000 Subject: [PATCH 05/44] [api] Inline ProtoVarInt::parse fast path and return consumed in struct (#14638) Co-authored-by: Claude Opus 4.6 --- .../api/api_frame_helper_plaintext.cpp | 24 +- esphome/components/api/api_pb2.cpp | 430 +++++++++--------- esphome/components/api/api_pb2.h | 102 ++--- esphome/components/api/proto.cpp | 75 +-- esphome/components/api/proto.h | 118 ++--- script/api_protobuf/api_protobuf.py | 20 +- 6 files changed, 376 insertions(+), 393 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 3c54ed7c70b..793cece3b82 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -128,37 +128,37 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // Skip indicator byte at position 0 uint8_t varint_pos = 1; - uint32_t consumed = 0; - auto msg_size_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed); + // rx_header_buf_pos_ >= 3 and varint_pos == 1, so len >= 2 + auto msg_size_varint = ProtoVarInt::parse_non_empty(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos); if (!msg_size_varint.has_value()) { // not enough data there yet continue; } - if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) { + if (msg_size_varint.value > MAX_MESSAGE_SIZE) { state_ = State::FAILED; - HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), - MAX_MESSAGE_SIZE); + HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", + static_cast(msg_size_varint.value), MAX_MESSAGE_SIZE); return APIError::BAD_DATA_PACKET; } - rx_header_parsed_len_ = msg_size_varint->as_uint16(); + rx_header_parsed_len_ = static_cast(msg_size_varint.value); // Move to next varint position - varint_pos += consumed; + varint_pos += msg_size_varint.consumed; - auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed); + auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos); if (!msg_type_varint.has_value()) { // not enough data there yet continue; } - if (msg_type_varint->as_uint32() > std::numeric_limits::max()) { + if (msg_type_varint.value > std::numeric_limits::max()) { state_ = State::FAILED; - HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", msg_type_varint->as_uint32(), - std::numeric_limits::max()); + HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", + static_cast(msg_type_varint.value), std::numeric_limits::max()); return APIError::BAD_DATA_PACKET; } - rx_header_parsed_type_ = msg_type_varint->as_uint16(); + rx_header_parsed_type_ = static_cast(msg_type_varint.value); rx_header_parsed_ = true; } // header reading done diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 6fce10ca0fe..01993cc5e5f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,13 +7,13 @@ namespace esphome::api { -bool HelloRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->api_version_major = value.as_uint32(); + this->api_version_major = value; break; case 3: - this->api_version_minor = value.as_uint32(); + this->api_version_minor = value; break; default: return false; @@ -316,20 +316,20 @@ uint32_t CoverStateResponse::calculate_size() const { #endif return size; } -bool CoverCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 4: - this->has_position = value.as_bool(); + this->has_position = value != 0; break; case 6: - this->has_tilt = value.as_bool(); + this->has_tilt = value != 0; break; case 8: - this->stop = value.as_bool(); + this->stop = value != 0; break; #ifdef USE_DEVICES case 9: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -423,38 +423,38 @@ uint32_t FanStateResponse::calculate_size() const { #endif return size; } -bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_state = value.as_bool(); + this->has_state = value != 0; break; case 3: - this->state = value.as_bool(); + this->state = value != 0; break; case 6: - this->has_oscillating = value.as_bool(); + this->has_oscillating = value != 0; break; case 7: - this->oscillating = value.as_bool(); + this->oscillating = value != 0; break; case 8: - this->has_direction = value.as_bool(); + this->has_direction = value != 0; break; case 9: - this->direction = static_cast(value.as_uint32()); + this->direction = static_cast(value); break; case 10: - this->has_speed_level = value.as_bool(); + this->has_speed_level = value != 0; break; case 11: - this->speed_level = value.as_int32(); + this->speed_level = static_cast(value); break; case 12: - this->has_preset_mode = value.as_bool(); + this->has_preset_mode = value != 0; break; #ifdef USE_DEVICES case 14: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -571,59 +571,59 @@ uint32_t LightStateResponse::calculate_size() const { #endif return size; } -bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_state = value.as_bool(); + this->has_state = value != 0; break; case 3: - this->state = value.as_bool(); + this->state = value != 0; break; case 4: - this->has_brightness = value.as_bool(); + this->has_brightness = value != 0; break; case 22: - this->has_color_mode = value.as_bool(); + this->has_color_mode = value != 0; break; case 23: - this->color_mode = static_cast(value.as_uint32()); + this->color_mode = static_cast(value); break; case 20: - this->has_color_brightness = value.as_bool(); + this->has_color_brightness = value != 0; break; case 6: - this->has_rgb = value.as_bool(); + this->has_rgb = value != 0; break; case 10: - this->has_white = value.as_bool(); + this->has_white = value != 0; break; case 12: - this->has_color_temperature = value.as_bool(); + this->has_color_temperature = value != 0; break; case 24: - this->has_cold_white = value.as_bool(); + this->has_cold_white = value != 0; break; case 26: - this->has_warm_white = value.as_bool(); + this->has_warm_white = value != 0; break; case 14: - this->has_transition_length = value.as_bool(); + this->has_transition_length = value != 0; break; case 15: - this->transition_length = value.as_uint32(); + this->transition_length = value; break; case 16: - this->has_flash_length = value.as_bool(); + this->has_flash_length = value != 0; break; case 17: - this->flash_length = value.as_uint32(); + this->flash_length = value; break; case 18: - this->has_effect = value.as_bool(); + this->has_effect = value != 0; break; #ifdef USE_DEVICES case 28: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -787,14 +787,14 @@ uint32_t SwitchStateResponse::calculate_size() const { #endif return size; } -bool SwitchCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->state = value.as_bool(); + this->state = value != 0; break; #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -863,13 +863,13 @@ uint32_t TextSensorStateResponse::calculate_size() const { return size; } #endif -bool SubscribeLogsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->level = static_cast(value.as_uint32()); + this->level = static_cast(value); break; case 2: - this->dump_config = value.as_bool(); + this->dump_config = value != 0; break; default: return false; @@ -971,13 +971,13 @@ uint32_t HomeassistantActionRequest::calculate_size() const { } #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -bool HomeassistantActionResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->call_id = value.as_uint32(); + this->call_id = value; break; case 2: - this->success = value.as_bool(); + this->success = value != 0; break; default: return false; @@ -1036,38 +1036,38 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel return true; } #endif -bool DSTRule::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->time_seconds = value.as_sint32(); + this->time_seconds = decode_zigzag32(static_cast(value)); break; case 2: - this->day = value.as_uint32(); + this->day = value; break; case 3: - this->type = static_cast(value.as_uint32()); + this->type = static_cast(value); break; case 4: - this->month = value.as_uint32(); + this->month = value; break; case 5: - this->week = value.as_uint32(); + this->week = value; break; case 6: - this->day_of_week = value.as_uint32(); + this->day_of_week = value; break; default: return false; } return true; } -bool ParsedTimezone::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->std_offset_seconds = value.as_sint32(); + this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; case 2: - this->dst_offset_seconds = value.as_sint32(); + this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; default: return false; @@ -1142,22 +1142,22 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, static_cast(this->supports_response)); return size; } -bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->bool_ = value.as_bool(); + this->bool_ = value != 0; break; case 2: - this->legacy_int = value.as_int32(); + this->legacy_int = static_cast(value); break; case 5: - this->int_ = value.as_sint32(); + this->int_ = decode_zigzag32(static_cast(value)); break; case 6: - this->bool_array.push_back(value.as_bool()); + this->bool_array.push_back(value != 0); break; case 7: - this->int_array.push_back(value.as_sint32()); + this->int_array.push_back(decode_zigzag32(static_cast(value))); break; default: return false; @@ -1202,16 +1202,16 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->string_array.init(count_string_array); ProtoDecodableMessage::decode(buffer, length); } -bool ExecuteServiceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES case 3: - this->call_id = value.as_uint32(); + this->call_id = value; break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES case 4: - this->return_response = value.as_bool(); + this->return_response = value != 0; break; #endif default: @@ -1313,13 +1313,13 @@ uint32_t CameraImageResponse::calculate_size() const { #endif return size; } -bool CameraImageRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->single = value.as_bool(); + this->single = value != 0; break; case 2: - this->stream = value.as_bool(); + this->stream = value != 0; break; default: return false; @@ -1468,53 +1468,53 @@ uint32_t ClimateStateResponse::calculate_size() const { #endif return size; } -bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_mode = value.as_bool(); + this->has_mode = value != 0; break; case 3: - this->mode = static_cast(value.as_uint32()); + this->mode = static_cast(value); break; case 4: - this->has_target_temperature = value.as_bool(); + this->has_target_temperature = value != 0; break; case 6: - this->has_target_temperature_low = value.as_bool(); + this->has_target_temperature_low = value != 0; break; case 8: - this->has_target_temperature_high = value.as_bool(); + this->has_target_temperature_high = value != 0; break; case 12: - this->has_fan_mode = value.as_bool(); + this->has_fan_mode = value != 0; break; case 13: - this->fan_mode = static_cast(value.as_uint32()); + this->fan_mode = static_cast(value); break; case 14: - this->has_swing_mode = value.as_bool(); + this->has_swing_mode = value != 0; break; case 15: - this->swing_mode = static_cast(value.as_uint32()); + this->swing_mode = static_cast(value); break; case 16: - this->has_custom_fan_mode = value.as_bool(); + this->has_custom_fan_mode = value != 0; break; case 18: - this->has_preset = value.as_bool(); + this->has_preset = value != 0; break; case 19: - this->preset = static_cast(value.as_uint32()); + this->preset = static_cast(value); break; case 20: - this->has_custom_preset = value.as_bool(); + this->has_custom_preset = value != 0; break; case 22: - this->has_target_humidity = value.as_bool(); + this->has_target_humidity = value != 0; break; #ifdef USE_DEVICES case 24: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -1631,21 +1631,21 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { size += ProtoSize::calc_float(1, this->target_temperature_high); return size; } -bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_fields = value.as_uint32(); + this->has_fields = value; break; case 3: - this->mode = static_cast(value.as_uint32()); + this->mode = static_cast(value); break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif case 6: - this->state = value.as_uint32(); + this->state = value; break; default: return false; @@ -1731,11 +1731,11 @@ uint32_t NumberStateResponse::calculate_size() const { #endif return size; } -bool NumberCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -1812,11 +1812,11 @@ uint32_t SelectStateResponse::calculate_size() const { #endif return size; } -bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -1903,29 +1903,29 @@ uint32_t SirenStateResponse::calculate_size() const { #endif return size; } -bool SirenCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_state = value.as_bool(); + this->has_state = value != 0; break; case 3: - this->state = value.as_bool(); + this->state = value != 0; break; case 4: - this->has_tone = value.as_bool(); + this->has_tone = value != 0; break; case 6: - this->has_duration = value.as_bool(); + this->has_duration = value != 0; break; case 7: - this->duration = value.as_uint32(); + this->duration = value; break; case 8: - this->has_volume = value.as_bool(); + this->has_volume = value != 0; break; #ifdef USE_DEVICES case 10: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2011,17 +2011,17 @@ uint32_t LockStateResponse::calculate_size() const { #endif return size; } -bool LockCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; case 3: - this->has_code = value.as_bool(); + this->has_code = value != 0; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2082,11 +2082,11 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { #endif return size; } -bool ButtonCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 2: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2182,29 +2182,29 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { #endif return size; } -bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_command = value.as_bool(); + this->has_command = value != 0; break; case 3: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; case 4: - this->has_volume = value.as_bool(); + this->has_volume = value != 0; break; case 6: - this->has_media_url = value.as_bool(); + this->has_media_url = value != 0; break; case 8: - this->has_announcement = value.as_bool(); + this->has_announcement = value != 0; break; case 9: - this->announcement = value.as_bool(); + this->announcement = value != 0; break; #ifdef USE_DEVICES case 10: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2238,10 +2238,10 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value } #endif #ifdef USE_BLUETOOTH_PROXY -bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->flags = value.as_uint32(); + this->flags = value; break; default: return false; @@ -2274,19 +2274,19 @@ uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } -bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->request_type = static_cast(value.as_uint32()); + this->request_type = static_cast(value); break; case 3: - this->has_address_type = value.as_bool(); + this->has_address_type = value != 0; break; case 4: - this->address_type = value.as_uint32(); + this->address_type = value; break; default: return false; @@ -2307,10 +2307,10 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } -bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; default: return false; @@ -2413,13 +2413,13 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { size += ProtoSize::calc_uint64(1, this->address); return size; } -bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; default: return false; @@ -2438,16 +2438,16 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len_); return size; } -bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; case 3: - this->response = value.as_bool(); + this->response = value != 0; break; default: return false; @@ -2466,26 +2466,26 @@ bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDeli } return true; } -bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; default: return false; } return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; default: return false; @@ -2504,16 +2504,16 @@ bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, Proto } return true; } -bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; case 3: - this->enable = value.as_bool(); + this->enable = value != 0; break; default: return false; @@ -2632,10 +2632,10 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, static_cast(this->configured_mode)); return size; } -bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->mode = static_cast(value.as_uint32()); + this->mode = static_cast(value); break; default: return false; @@ -2644,13 +2644,13 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarIn } #endif #ifdef USE_VOICE_ASSISTANT -bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->subscribe = value.as_bool(); + this->subscribe = value != 0; break; case 2: - this->flags = value.as_uint32(); + this->flags = value; break; default: return false; @@ -2685,13 +2685,13 @@ uint32_t VoiceAssistantRequest::calculate_size() const { size += ProtoSize::calc_length(1, this->wake_word_phrase.size()); return size; } -bool VoiceAssistantResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->port = value.as_uint32(); + this->port = value; break; case 2: - this->error = value.as_bool(); + this->error = value != 0; break; default: return false; @@ -2713,10 +2713,10 @@ bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimi } return true; } -bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->event_type = static_cast(value.as_uint32()); + this->event_type = static_cast(value); break; default: return false; @@ -2734,10 +2734,10 @@ bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDe } return true; } -bool VoiceAssistantAudio::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->end = value.as_bool(); + this->end = value != 0; break; default: return false; @@ -2766,19 +2766,19 @@ uint32_t VoiceAssistantAudio::calculate_size() const { size += ProtoSize::calc_bool(1, this->end); return size; } -bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->event_type = static_cast(value.as_uint32()); + this->event_type = static_cast(value); break; case 4: - this->total_seconds = value.as_uint32(); + this->total_seconds = value; break; case 5: - this->seconds_left = value.as_uint32(); + this->seconds_left = value; break; case 6: - this->is_active = value.as_bool(); + this->is_active = value != 0; break; default: return false; @@ -2800,10 +2800,10 @@ bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLen } return true; } -bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 4: - this->start_conversation = value.as_bool(); + this->start_conversation = value != 0; break; default: return false; @@ -2853,10 +2853,10 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } return size; } -bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 5: - this->model_size = value.as_uint32(); + this->model_size = value; break; default: return false; @@ -2990,14 +2990,14 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { #endif return size; } -bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; #ifdef USE_DEVICES case 4: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3082,11 +3082,11 @@ uint32_t TextStateResponse::calculate_size() const { #endif return size; } -bool TextCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3167,20 +3167,20 @@ uint32_t DateStateResponse::calculate_size() const { #endif return size; } -bool DateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->year = value.as_uint32(); + this->year = value; break; case 3: - this->month = value.as_uint32(); + this->month = value; break; case 4: - this->day = value.as_uint32(); + this->day = value; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3250,20 +3250,20 @@ uint32_t TimeStateResponse::calculate_size() const { #endif return size; } -bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->hour = value.as_uint32(); + this->hour = value; break; case 3: - this->minute = value.as_uint32(); + this->minute = value; break; case 4: - this->second = value.as_uint32(); + this->second = value; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3393,17 +3393,17 @@ uint32_t ValveStateResponse::calculate_size() const { #endif return size; } -bool ValveCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_position = value.as_bool(); + this->has_position = value != 0; break; case 4: - this->stop = value.as_bool(); + this->stop = value != 0; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3472,11 +3472,11 @@ uint32_t DateTimeStateResponse::calculate_size() const { #endif return size; } -bool DateTimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3561,14 +3561,14 @@ uint32_t UpdateStateResponse::calculate_size() const { #endif return size; } -bool UpdateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3606,10 +3606,10 @@ uint32_t ZWaveProxyFrame::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len); return size; } -bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->type = static_cast(value.as_uint32()); + this->type = static_cast(value); break; default: return false; @@ -3672,18 +3672,18 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { } #endif #ifdef USE_IR_RF -bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 1: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif case 3: - this->carrier_frequency = value.as_uint32(); + this->carrier_frequency = value; break; case 4: - this->repeat_count = value.as_uint32(); + this->repeat_count = value; break; default: return false; @@ -3737,25 +3737,25 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { } #endif #ifdef USE_SERIAL_PROXY -bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; case 2: - this->baudrate = value.as_uint32(); + this->baudrate = value; break; case 3: - this->flow_control = value.as_bool(); + this->flow_control = value != 0; break; case 4: - this->parity = static_cast(value.as_uint32()); + this->parity = static_cast(value); break; case 5: - this->stop_bits = value.as_uint32(); + this->stop_bits = value; break; case 6: - this->data_size = value.as_uint32(); + this->data_size = value; break; default: return false; @@ -3772,10 +3772,10 @@ uint32_t SerialProxyDataReceived::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len_); return size; } -bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; default: return false; @@ -3794,23 +3794,23 @@ bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimi } return true; } -bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; case 2: - this->line_states = value.as_uint32(); + this->line_states = value; break; default: return false; } return true; } -bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; default: return false; @@ -3827,13 +3827,13 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, this->line_states); return size; } -bool SerialProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; case 2: - this->type = static_cast(value.as_uint32()); + this->type = static_cast(value); break; default: return false; @@ -3856,22 +3856,22 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { } #endif #ifdef USE_BLUETOOTH_PROXY -bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->min_interval = value.as_uint32(); + this->min_interval = value; break; case 3: - this->max_interval = value.as_uint32(); + this->max_interval = value; break; case 4: - this->latency = value.as_uint32(); + this->latency = value; break; case 5: - this->timeout = value.as_uint32(); + this->timeout = value; break; default: return false; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5c712508b9a..a4ee0adb8b5 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -399,7 +399,7 @@ class HelloRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class HelloResponse final : public ProtoMessage { public: @@ -688,7 +688,7 @@ class CoverCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_FAN @@ -756,7 +756,7 @@ class FanCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_LIGHT @@ -846,7 +846,7 @@ class LightCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_SENSOR @@ -936,7 +936,7 @@ class SwitchCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_TEXT_SENSOR @@ -988,7 +988,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SubscribeLogsResponse final : public ProtoMessage { public: @@ -1110,7 +1110,7 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1176,7 +1176,7 @@ class DSTRule final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class ParsedTimezone final : public ProtoDecodableMessage { public: @@ -1190,7 +1190,7 @@ class ParsedTimezone final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class GetTimeResponse final : public ProtoDecodableMessage { public: @@ -1261,7 +1261,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: @@ -1286,7 +1286,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1365,7 +1365,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_CLIMATE @@ -1464,7 +1464,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_WATER_HEATER @@ -1528,7 +1528,7 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_NUMBER @@ -1584,7 +1584,7 @@ class NumberCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_SELECT @@ -1636,7 +1636,7 @@ class SelectCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_SIREN @@ -1696,7 +1696,7 @@ class SirenCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_LOCK @@ -1752,7 +1752,7 @@ class LockCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_BUTTON @@ -1785,7 +1785,7 @@ class ButtonCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_MEDIA_PLAYER @@ -1862,7 +1862,7 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -1879,7 +1879,7 @@ class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMes #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothLERawAdvertisement final : public ProtoMessage { public: @@ -1929,7 +1929,7 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: @@ -1963,7 +1963,7 @@ class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTDescriptor final : public ProtoMessage { public: @@ -2054,7 +2054,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTReadResponse final : public ProtoMessage { public: @@ -2097,7 +2097,7 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2113,7 +2113,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2132,7 +2132,7 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: @@ -2149,7 +2149,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: @@ -2329,7 +2329,7 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_VOICE_ASSISTANT @@ -2347,7 +2347,7 @@ class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAudioSettings final : public ProtoMessage { public: @@ -2396,7 +2396,7 @@ class VoiceAssistantResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantEventData final : public ProtoDecodableMessage { public: @@ -2424,7 +2424,7 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: @@ -2444,7 +2444,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: @@ -2465,7 +2465,7 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: @@ -2484,7 +2484,7 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: @@ -2530,7 +2530,7 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: @@ -2632,7 +2632,7 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_TEXT @@ -2687,7 +2687,7 @@ class TextCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_DATETIME_DATE @@ -2741,7 +2741,7 @@ class DateCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_DATETIME_TIME @@ -2795,7 +2795,7 @@ class TimeCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_EVENT @@ -2886,7 +2886,7 @@ class ValveCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_DATETIME_DATETIME @@ -2936,7 +2936,7 @@ class DateTimeCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_UPDATE @@ -2994,7 +2994,7 @@ class UpdateCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_ZWAVE_PROXY @@ -3034,7 +3034,7 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_INFRARED @@ -3079,7 +3079,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class InfraredRFReceiveEvent final : public ProtoMessage { public: @@ -3121,7 +3121,7 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyDataReceived final : public ProtoMessage { public: @@ -3161,7 +3161,7 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3177,7 +3177,7 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3192,7 +3192,7 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: @@ -3225,7 +3225,7 @@ class SerialProxyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyRequestResponse final : public ProtoMessage { public: @@ -3265,7 +3265,7 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index fb229928e5a..4f5b3f0918f 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -20,20 +20,40 @@ void ProtoWriteBuffer::encode_varint_raw_slow_(uint32_t value) { *this->pos_++ = static_cast(value); } +ProtoVarIntResult ProtoVarInt::parse_slow(const uint8_t *buffer, uint32_t len) { + // Multi-byte varint: first byte already checked to have high bit set + uint32_t result32 = buffer[0] & 0x7F; #ifdef USE_API_VARINT64 -optional ProtoVarInt::parse_wide(const uint8_t *buffer, uint32_t len, uint32_t *consumed, - uint32_t result32) { + uint32_t limit = std::min(len, uint32_t(4)); +#else + uint32_t limit = std::min(len, uint32_t(5)); +#endif + for (uint32_t i = 1; i < limit; i++) { + uint8_t val = buffer[i]; + result32 |= uint32_t(val & 0x7F) << (i * 7); + if ((val & 0x80) == 0) { + return {result32, i + 1}; + } + } +#ifdef USE_API_VARINT64 + return parse_wide(buffer, len, result32); +#else + return {0, PROTO_VARINT_PARSE_FAILED}; +#endif +} + +#ifdef USE_API_VARINT64 +ProtoVarIntResult ProtoVarInt::parse_wide(const uint8_t *buffer, uint32_t len, uint32_t result32) { uint64_t result64 = result32; uint32_t limit = std::min(len, uint32_t(10)); for (uint32_t i = 4; i < limit; i++) { uint8_t val = buffer[i]; result64 |= uint64_t(val & 0x7F) << (i * 7); if ((val & 0x80) == 0) { - *consumed = i + 1; - return ProtoVarInt(result64); + return {result64, i + 1}; } } - return {}; + return {0, PROTO_VARINT_PARSE_FAILED}; } #endif @@ -43,18 +63,16 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size const uint8_t *end = buffer + length; while (ptr < end) { - uint32_t consumed; - - // Parse field header (tag) - auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + // Parse field header (tag) - ptr < end guarantees len >= 1 + auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr); if (!res.has_value()) { break; // Invalid data, stop counting } - uint32_t tag = res->as_uint32(); + uint32_t tag = static_cast(res.value); uint32_t field_type = tag & WIRE_TYPE_MASK; uint32_t field_id = tag >> 3; - ptr += consumed; + ptr += res.consumed; // Count if this is the target field if (field_id == target_field_id) { @@ -64,20 +82,20 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size // Skip field data based on wire type switch (field_type) { case WIRE_TYPE_VARINT: { // VarInt - parse and skip - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { return count; // Invalid data, return what we have } - ptr += consumed; + ptr += res.consumed; break; } case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - parse length and skip data - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { return count; } - uint32_t field_length = res->as_uint32(); - ptr += consumed; + uint32_t field_length = static_cast(res.value); + ptr += res.consumed; if (field_length > static_cast(end - ptr)) { return count; // Out of bounds } @@ -190,41 +208,40 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { const uint8_t *end = buffer + length; while (ptr < end) { - uint32_t consumed; - - // Parse field header - auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + // Parse field header - ptr < end guarantees len >= 1 + auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer)); return; } - uint32_t tag = res->as_uint32(); + uint32_t tag = static_cast(res.value); uint32_t field_type = tag & WIRE_TYPE_MASK; uint32_t field_id = tag >> 3; - ptr += consumed; + ptr += res.consumed; switch (field_type) { case WIRE_TYPE_VARINT: { // VarInt - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); return; } - if (!this->decode_varint(field_id, *res)) { - ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu32 "!", field_id, res->as_uint32()); + if (!this->decode_varint(field_id, res.value)) { + ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu64 "!", field_id, + static_cast(res.value)); } - ptr += consumed; + ptr += res.consumed; break; } case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); return; } - uint32_t field_length = res->as_uint32(); - ptr += consumed; + uint32_t field_length = static_cast(res.value); + ptr += res.consumed; if (field_length > static_cast(end - ptr)) { ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); return; diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index adde0a8a85b..7050efb4460 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -98,90 +98,56 @@ inline void encode_varint_to_buffer(uint32_t val, uint8_t *buffer) { * within the same function scope where temporaries are created. */ -/// Representation of a VarInt - in ProtoBuf should be 64bit but we only use 32bit +/// Type used for decoded varint values - uint64_t when BLE needs 64-bit addresses, uint32_t otherwise +#ifdef USE_API_VARINT64 +using proto_varint_value_t = uint64_t; +#else +using proto_varint_value_t = uint32_t; +#endif + +/// Sentinel value for consumed field indicating parse failure +inline constexpr uint32_t PROTO_VARINT_PARSE_FAILED = 0; + +/// Result of parsing a varint: value + number of bytes consumed. +/// consumed == PROTO_VARINT_PARSE_FAILED indicates parse failure (not enough data or invalid). +struct ProtoVarIntResult { + proto_varint_value_t value; + uint32_t consumed; // PROTO_VARINT_PARSE_FAILED = parse failed + + constexpr bool has_value() const { return this->consumed != PROTO_VARINT_PARSE_FAILED; } +}; + +/// Static varint parsing methods for the protobuf wire format. class ProtoVarInt { public: - ProtoVarInt() : value_(0) {} - explicit ProtoVarInt(uint64_t value) : value_(value) {} - - /// Parse a varint from buffer. consumed must be a valid pointer (not null). - static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { + /// Parse a varint from buffer. Caller must ensure len >= 1. + /// Returns result with consumed=0 on failure (truncated multi-byte varint). + static inline ProtoVarIntResult ESPHOME_ALWAYS_INLINE parse_non_empty(const uint8_t *buffer, uint32_t len) { #ifdef ESPHOME_DEBUG_API - assert(consumed != nullptr); + assert(len > 0); #endif - if (len == 0) - return {}; // Fast path: single-byte varints (0-127) are the most common case - // (booleans, small enums, field tags). Avoid loop overhead entirely. - if ((buffer[0] & 0x80) == 0) { - *consumed = 1; - return ProtoVarInt(buffer[0]); - } - // 32-bit phase: process remaining bytes with native 32-bit shifts. - // Without USE_API_VARINT64: cover bytes 1-4 (shifts 7, 14, 21, 28) — the uint32_t - // shift at byte 4 (shift by 28) may lose bits 32-34, but those are always zero for valid uint32 values. - // With USE_API_VARINT64: cover bytes 1-3 (shifts 7, 14, 21) so parse_wide handles - // byte 4+ with full 64-bit arithmetic (avoids truncating values > UINT32_MAX). - uint32_t result32 = buffer[0] & 0x7F; -#ifdef USE_API_VARINT64 - uint32_t limit = std::min(len, uint32_t(4)); -#else - uint32_t limit = std::min(len, uint32_t(5)); -#endif - for (uint32_t i = 1; i < limit; i++) { - uint8_t val = buffer[i]; - result32 |= uint32_t(val & 0x7F) << (i * 7); - if ((val & 0x80) == 0) { - *consumed = i + 1; - return ProtoVarInt(result32); - } - } - // 64-bit phase for remaining bytes (BLE addresses etc.) -#ifdef USE_API_VARINT64 - return parse_wide(buffer, len, consumed, result32); -#else - return {}; -#endif + // (booleans, small enums, field tags, small message sizes/types). + if ((buffer[0] & 0x80) == 0) [[likely]] + return {buffer[0], 1}; + return parse_slow(buffer, len); + } + + /// Parse a varint from buffer (safe for empty buffers). + /// Returns result with consumed=0 on failure (empty buffer or truncated varint). + static inline ProtoVarIntResult ESPHOME_ALWAYS_INLINE parse(const uint8_t *buffer, uint32_t len) { + if (len == 0) + return {0, PROTO_VARINT_PARSE_FAILED}; + return parse_non_empty(buffer, len); } -#ifdef USE_API_VARINT64 protected: + // Slow path for multi-byte varints (>= 128), outlined to keep fast path small + static ProtoVarIntResult parse_slow(const uint8_t *buffer, uint32_t len) __attribute__((noinline)); + +#ifdef USE_API_VARINT64 /// Continue parsing varint bytes 4-9 with 64-bit arithmetic. - /// Separated to keep 64-bit shift code (__ashldi3 on 32-bit platforms) out of the common path. - static optional parse_wide(const uint8_t *buffer, uint32_t len, uint32_t *consumed, uint32_t result32) - __attribute__((noinline)); - - public: -#endif - - constexpr uint16_t as_uint16() const { return this->value_; } - constexpr uint32_t as_uint32() const { return this->value_; } - constexpr bool as_bool() const { return this->value_; } - constexpr int32_t as_int32() const { - // Not ZigZag encoded - return static_cast(this->value_); - } - constexpr int32_t as_sint32() const { - // with ZigZag encoding - return decode_zigzag32(static_cast(this->value_)); - } -#ifdef USE_API_VARINT64 - constexpr uint64_t as_uint64() const { return this->value_; } - constexpr int64_t as_int64() const { - // Not ZigZag encoded - return static_cast(this->value_); - } - constexpr int64_t as_sint64() const { - // with ZigZag encoding - return decode_zigzag64(this->value_); - } -#endif - - protected: -#ifdef USE_API_VARINT64 - uint64_t value_; -#else - uint32_t value_; + static ProtoVarIntResult parse_wide(const uint8_t *buffer, uint32_t len, uint32_t result32) __attribute__((noinline)); #endif }; @@ -499,7 +465,7 @@ class ProtoDecodableMessage : public ProtoMessage { protected: ~ProtoDecodableMessage() = default; - virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } + virtual bool decode_varint(uint32_t field_id, proto_varint_value_t value) { return false; } virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } // NOTE: decode_64bit removed - wire type 1 not supported diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 206f8f558bd..b4044c362c6 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -461,7 +461,7 @@ class FloatType(TypeInfo): class Int64Type(TypeInfo): cpp_type = "int64_t" default_value = "0" - decode_varint = "value.as_int64()" + decode_varint = "static_cast(value)" encode_func = "encode_int64" wire_type = WireType.VARINT # Uses wire type 0 @@ -481,7 +481,7 @@ class Int64Type(TypeInfo): class UInt64Type(TypeInfo): cpp_type = "uint64_t" default_value = "0" - decode_varint = "value.as_uint64()" + decode_varint = "value" encode_func = "encode_uint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -501,7 +501,7 @@ class UInt64Type(TypeInfo): class Int32Type(TypeInfo): cpp_type = "int32_t" default_value = "0" - decode_varint = "value.as_int32()" + decode_varint = "static_cast(value)" encode_func = "encode_int32" wire_type = WireType.VARINT # Uses wire type 0 @@ -573,7 +573,7 @@ class Fixed32Type(TypeInfo): class BoolType(TypeInfo): cpp_type = "bool" default_value = "false" - decode_varint = "value.as_bool()" + decode_varint = "value != 0" encode_func = "encode_bool" wire_type = WireType.VARINT # Uses wire type 0 @@ -1151,7 +1151,7 @@ class FixedArrayBytesType(TypeInfo): class UInt32Type(TypeInfo): cpp_type = "uint32_t" default_value = "0" - decode_varint = "value.as_uint32()" + decode_varint = "value" encode_func = "encode_uint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1175,7 +1175,7 @@ class EnumType(TypeInfo): @property def decode_varint(self) -> str: - return f"static_cast<{self.cpp_type}>(value.as_uint32())" + return f"static_cast<{self.cpp_type}>(value)" default_value = "" wire_type = WireType.VARINT # Uses wire type 0 @@ -1262,7 +1262,7 @@ class SFixed64Type(TypeInfo): class SInt32Type(TypeInfo): cpp_type = "int32_t" default_value = "0" - decode_varint = "value.as_sint32()" + decode_varint = "decode_zigzag32(static_cast(value))" encode_func = "encode_sint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1282,7 +1282,7 @@ class SInt32Type(TypeInfo): class SInt64Type(TypeInfo): cpp_type = "int64_t" default_value = "0" - decode_varint = "value.as_sint64()" + decode_varint = "decode_zigzag64(value)" encode_func = "encode_sint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -2205,7 +2205,7 @@ def build_message_type( cpp = "" if decode_varint: - o = f"bool {desc.name}::decode_varint(uint32_t field_id, ProtoVarInt value) {{\n" + o = f"bool {desc.name}::decode_varint(uint32_t field_id, proto_varint_value_t value) {{\n" o += " switch (field_id) {\n" o += indent("\n".join(decode_varint), " ") + "\n" o += " default: return false;\n" @@ -2213,7 +2213,7 @@ def build_message_type( o += " return true;\n" o += "}\n" cpp += o - prot = "bool decode_varint(uint32_t field_id, ProtoVarInt value) override;" + prot = "bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;" protected_content.insert(0, prot) if decode_length: o = f"bool {desc.name}::decode_length(uint32_t field_id, ProtoLengthDelimited value) {{\n" From c709010c4cb04c7276f93af781dee82bf566ca85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:11:12 -1000 Subject: [PATCH 06/44] [api] Replace std::vector with APIBuffer to skip zero-fill (#14593) --- esphome/components/api/api_buffer.cpp | 13 ++++ esphome/components/api/api_buffer.h | 67 +++++++++++++++++++ esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_connection.h | 8 +-- esphome/components/api/api_frame_helper.h | 10 +-- .../components/api/api_frame_helper_noise.cpp | 7 +- .../components/api/api_frame_helper_noise.h | 4 +- .../api/api_frame_helper_plaintext.cpp | 4 +- esphome/components/api/api_server.h | 5 +- esphome/components/api/proto.h | 10 +-- tests/components/api/test.ln882x-ard.yaml | 5 ++ 11 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 esphome/components/api/api_buffer.cpp create mode 100644 esphome/components/api/api_buffer.h create mode 100644 tests/components/api/test.ln882x-ard.yaml diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp new file mode 100644 index 00000000000..6db18b0365e --- /dev/null +++ b/esphome/components/api/api_buffer.cpp @@ -0,0 +1,13 @@ +#include "api_buffer.h" + +namespace esphome::api { + +void APIBuffer::grow_(size_t n) { + auto new_data = make_buffer(n); + if (this->size_) + std::memcpy(new_data.get(), this->data_.get(), this->size_); + this->data_ = std::move(new_data); + this->capacity_ = n; +} + +} // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h new file mode 100644 index 00000000000..00801e3ee58 --- /dev/null +++ b/esphome/components/api/api_buffer.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::api { + +/// Helper to use make_unique_for_overwrite where available (skips zero-fill), +/// falling back to make_unique on older GCC (ESP8266, LibreTiny). +inline std::unique_ptr make_buffer(size_t n) { +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) + return std::make_unique(n); +#else + return std::make_unique_for_overwrite(n); +#endif +} + +/// Byte buffer that skips zero-initialization on resize(). +/// +/// std::vector::resize() zero-fills new bytes via memset. For the +/// shared protobuf write buffer, every byte is overwritten by the encoder, +/// making the zero-fill pure waste. For the receive buffer, bytes are +/// overwritten by socket reads. +/// +/// Designed for bulk clear/resize/overwrite patterns. grow_() allocates +/// exactly the requested size (no growth factor) since callers resize to +/// known sizes rather than appending incrementally. +/// +/// Safe because: callers always write exactly the number of bytes they +/// resize for. In the protobuf write path, debug_check_bounds_ validates +/// writes in debug builds. +class APIBuffer { + public: + void clear() { this->size_ = 0; } + inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE { + if (n > this->capacity_) + this->grow_(n); + } + inline void resize(size_t n) ESPHOME_ALWAYS_INLINE { + this->reserve(n); + this->size_ = n; // no zero-fill + } + uint8_t *data() { return this->data_.get(); } + const uint8_t *data() const { return this->data_.get(); } + size_t size() const { return this->size_; } + bool empty() const { return this->size_ == 0; } + uint8_t &operator[](size_t i) { return this->data_[i]; } + const uint8_t &operator[](size_t i) const { return this->data_[i]; } + /// Release all memory (equivalent to std::vector swap trick). + void release() { + this->data_.reset(); + this->size_ = 0; + this->capacity_ = 0; + } + + protected: + void grow_(size_t n); + std::unique_ptr data_; + size_t size_{0}; + size_t capacity_{0}; +}; + +} // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7bd5d5120b8..dea3ba5460b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2016,7 +2016,7 @@ uint16_t APIConnection::encode_to_buffer(uint32_t calculated_size, MessageEncode if (total_calculated_size > remaining_size) return 0; // Doesn't fit - std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); if (conn->flags_.batch_first_message) { // First message - buffer already prepared by caller, just clear flag @@ -2184,7 +2184,7 @@ void APIConnection::process_batch_() { // Separated from process_batch_() so the single-message fast path gets a minimal // stack frame without the MAX_MESSAGES_PER_BATCH * sizeof(MessageInfo) array. -void APIConnection::process_batch_multi_(std::vector &shared_buf, size_t num_items, uint8_t header_padding, +void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size) { // Ensure MessageInfo remains trivially destructible for our placement new approach static_assert(std::is_trivially_destructible::value, diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index ccb51186d62..3356511684f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -288,7 +288,7 @@ class APIConnection final : public APIServerConnectionBase { } } - void prepare_first_message_buffer(std::vector &shared_buf, size_t header_padding, size_t total_size) { + void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) { shared_buf.clear(); // Reserve space for header padding + message + footer // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) @@ -299,7 +299,7 @@ class APIConnection final : public APIServerConnectionBase { } // Convenience overload - computes frame overhead internally - void prepare_first_message_buffer(std::vector &shared_buf, size_t payload_size) { + void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) { const uint8_t header_padding = this->helper_->frame_header_padding(); const uint8_t footer_size = this->helper_->frame_footer_size(); this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size); @@ -687,8 +687,8 @@ class APIConnection final : public APIServerConnectionBase { bool schedule_batch_(); void process_batch_(); - void process_batch_multi_(std::vector &shared_buf, size_t num_items, uint8_t header_padding, - uint8_t footer_size) __attribute__((noinline)); + void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size) + __attribute__((noinline)); void clear_batch_() { this->deferred_batch_.clear(); this->flags_.batch_scheduled = false; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 151314658ea..98de24501ea 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -5,10 +5,10 @@ #include #include #include -#include #include "esphome/core/defines.h" #ifdef USE_API +#include "esphome/components/api/api_buffer.h" #include "esphome/components/socket/socket.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -178,8 +178,7 @@ class APIFrameHelper { // rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame // and clearing would lose partially received data. if (this->rx_buf_len_ == 0) { - // Use swap trick since shrink_to_fit() is non-binding and may be ignored - std::vector().swap(this->rx_buf_); + this->rx_buf_.release(); } } @@ -206,9 +205,6 @@ class APIFrameHelper { // Common socket write error handling APIError handle_socket_write_error_(); - template - APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector &tx_buf, - const std::string &info, StateEnum &state, StateEnum failed_state); // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) std::unique_ptr socket_; @@ -245,7 +241,7 @@ class APIFrameHelper { // Containers (size varies, but typically 12+ bytes on 32-bit) std::array, API_MAX_SEND_QUEUE> tx_buf_; - std::vector rx_buf_; + APIBuffer rx_buf_; // Client name buffer - stores name from Hello message or initial peername char client_name_[CLIENT_INFO_NAME_MAX_LEN]{}; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 256357ce6a7..3e6ecf9dc30 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -207,9 +207,7 @@ APIError APINoiseFrameHelper::try_read_frame_() { // During handshake, rx_buf_.size() is used in prologue construction, so // the buffer must be exactly msg_size to avoid prologue mismatch.) uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0); - if (this->rx_buf_.size() != alloc_size) { - this->rx_buf_.resize(alloc_size); - } + this->rx_buf_.resize(alloc_size); if (rx_buf_len_ < msg_size) { // more data to read @@ -571,8 +569,7 @@ APIError APINoiseFrameHelper::init_handshake_() { if (aerr != APIError::OK) return aerr; // set_prologue copies it into handshakestate, so we can get rid of it now - // Use swap idiom to actually release memory (= {} only clears size, not capacity) - std::vector().swap(prologue_); + prologue_.release(); err = noise_handshakestate_start(handshake_); aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 183b8c8a51d..83410febb26 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -43,8 +43,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Reference to noise context (4 bytes on 32-bit) APINoiseContext &ctx_; - // Vector (12 bytes on 32-bit) - std::vector prologue_; + // Buffer for noise handshake prologue (released after handshake) + APIBuffer prologue_; // NoiseProtocolId (size depends on implementation) NoiseProtocolId nid_; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 793cece3b82..007da7ef2b1 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -165,9 +165,7 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // Reserve space for body (+ null terminator so protobuf StringRef fields // can be safely null-terminated in-place after decode) - if (this->rx_buf_.size() != this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR) { - this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR); - } + this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR); if (rx_buf_len_ < rx_header_parsed_len_) { // more data to read diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e5f371d8a13..69fc26cc00c 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -2,6 +2,7 @@ #include "esphome/core/defines.h" #ifdef USE_API +#include "api_buffer.h" #include "api_noise_context.h" #include "api_pb2.h" #include "api_pb2_service.h" @@ -65,7 +66,7 @@ class APIServer : public Component, void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections - std::vector &get_shared_buffer_ref() { return shared_write_buffer_; } + APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } #ifdef USE_API_NOISE bool save_noise_psk(psk_t psk, bool make_active = true); @@ -276,7 +277,7 @@ class APIServer : public Component, // Not pre-allocated: all send paths call prepare_first_message_buffer() which // reserves the exact needed size. Pre-allocating here would cause heap fragmentation // since the buffer would almost always reallocate on first use. - std::vector shared_write_buffer_; + APIBuffer shared_write_buffer_; #ifdef USE_API_HOMEASSISTANT_STATES std::vector state_subs_; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 7050efb4460..d1c955b1fb9 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -1,6 +1,7 @@ #pragma once #include "api_pb2_defines.h" +#include "api_buffer.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -203,9 +204,8 @@ class Proto32Bit { class ProtoWriteBuffer { public: - ProtoWriteBuffer(std::vector *buffer) : buffer_(buffer), pos_(buffer->data() + buffer->size()) {} - ProtoWriteBuffer(std::vector *buffer, size_t write_pos) - : buffer_(buffer), pos_(buffer->data() + write_pos) {} + ProtoWriteBuffer(APIBuffer *buffer) : buffer_(buffer), pos_(buffer->data() + buffer->size()) {} + ProtoWriteBuffer(APIBuffer *buffer, size_t write_pos) : buffer_(buffer), pos_(buffer->data() + write_pos) {} inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint32_t value) { if (value < 128) [[likely]] { this->debug_check_bounds_(1); @@ -340,7 +340,7 @@ class ProtoWriteBuffer { // Non-template core for encode_optional_sub_message. void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, void (*encode_fn)(const void *, ProtoWriteBuffer &)); - std::vector *get_buffer() const { return buffer_; } + APIBuffer *get_buffer() const { return buffer_; } protected: // Slow path for encode_varint_raw values >= 128, outlined to keep fast path small @@ -353,7 +353,7 @@ class ProtoWriteBuffer { void debug_check_bounds_([[maybe_unused]] size_t bytes) {} #endif - std::vector *buffer_; + APIBuffer *buffer_; uint8_t *pos_; }; diff --git a/tests/components/api/test.ln882x-ard.yaml b/tests/components/api/test.ln882x-ard.yaml new file mode 100644 index 00000000000..46c01d926f2 --- /dev/null +++ b/tests/components/api/test.ln882x-ard.yaml @@ -0,0 +1,5 @@ +<<: !include common.yaml + +wifi: + ssid: MySSID + password: password1 From 9dd3ec258c2f1ebf636cd7765981df2f1711c4a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:11:28 -1000 Subject: [PATCH 07/44] [scheduler] Replace unique_ptr with raw pointers, add leak detection (#14620) --- esphome/core/scheduler.cpp | 179 +++++++++++------- esphome/core/scheduler.h | 94 +++++---- .../fixtures/scheduler_bulk_cleanup.yaml | 1 + .../fixtures/scheduler_defer_cancel.yaml | 1 + .../scheduler_defer_cancels_regular.yaml | 1 + .../fixtures/scheduler_defer_fifo_simple.yaml | 1 + .../fixtures/scheduler_defer_stress.yaml | 1 + .../fixtures/scheduler_heap_stress.yaml | 1 + .../scheduler_internal_id_no_collision.yaml | 1 + .../fixtures/scheduler_null_name.yaml | 1 + .../fixtures/scheduler_numeric_id_test.yaml | 1 + .../scheduler_rapid_cancellation.yaml | 1 + .../fixtures/scheduler_recursive_timeout.yaml | 1 + .../fixtures/scheduler_removed_item_race.yaml | 1 + .../fixtures/scheduler_retry_test.yaml | 1 + .../scheduler_simultaneous_callbacks.yaml | 1 + .../fixtures/scheduler_string_lifetime.yaml | 1 + .../scheduler_string_name_stress.yaml | 1 + 18 files changed, 172 insertions(+), 117 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index ca560e8250a..63e1006b03c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -30,11 +30,6 @@ static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; // max delay to start an interval sequence static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; -// Prevent inlining of SchedulerItem deletion. On BK7231N (Thumb-1), GCC inlines -// ~unique_ptr (~30 bytes each) at every destruction site. Defining -// the deleter in the .cpp file ensures a single copy of the destructor + operator delete. -void Scheduler::SchedulerItemDeleter::operator()(SchedulerItem *ptr) const noexcept { delete ptr; } - #if defined(ESPHOME_LOG_HAS_VERBOSE) || defined(ESPHOME_DEBUG_SCHEDULER) // Helper struct for formatting scheduler item names consistently in logs // Uses a stack buffer to avoid heap allocation @@ -122,8 +117,8 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id) { for (auto *container : {&this->items_, &this->to_add_}) { - for (auto &item : *container) { - if (item && this->is_item_removed_locked_(item.get()) && + for (auto *item : *container) { + if (item != nullptr && this->is_item_removed_locked_(item) && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, /* match_retry= */ true, /* skip_removed= */ false)) { return true; @@ -147,17 +142,31 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // Take lock early to protect scheduler_item_pool_ access + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; + // For retries, check if there's a cancelled timeout first - before allocating an item. + // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name + // Skip check for defer (delay=0) - deferred retries bypass the cancellation check + if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) && + type == SchedulerItem::TIMEOUT && + this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { +#ifdef ESPHOME_DEBUG_SCHEDULER + SchedulerNameLog skip_name_log; + ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", + skip_name_log.format(name_type, static_name, hash_or_id)); +#endif + return; + } + // Create and populate the scheduler item - auto item = this->get_item_from_pool_locked_(); + SchedulerItem *item = this->get_item_from_pool_locked_(); item->component = component; item->set_name(name_type, static_name, hash_or_id); item->type = type; item->callback = std::move(func); // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use - this->set_item_removed_(item.get(), false); + this->set_item_removed_(item, false); item->is_retry = is_retry; // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. @@ -193,29 +202,15 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } #ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_log_timer_(item.get(), name_type, static_name, hash_or_id, type, delay, now_64); + this->debug_log_timer_(item, name_type, static_name, hash_or_id, type, delay, now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - - // For retries, check if there's a cancelled timeout first - // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name - if (is_retry && (name_type != NameType::STATIC_STRING || static_name != nullptr) && - type == SchedulerItem::TIMEOUT && - this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { - // Skip scheduling - the retry was cancelled -#ifdef ESPHOME_DEBUG_SCHEDULER - SchedulerNameLog skip_name_log; - ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", - skip_name_log.format(name_type, static_name, hash_or_id)); -#endif - return; - } } // Common epilogue: atomic cancel-and-add (unless skip_cancel is true) if (!skip_cancel) { this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } - target->push_back(std::move(item)); + target->push_back(item); } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, @@ -395,7 +390,7 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { if (this->cleanup_() == 0) return {}; - auto &item = this->items_[0]; + SchedulerItem *item = this->items_[0]; const auto now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) @@ -414,13 +409,13 @@ void Scheduler::full_cleanup_removed_items_() { // 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_locked_(this->items_[read].get())) { + if (!is_item_removed_locked_(this->items_[read])) { if (write != read) { - this->items_[write] = std::move(this->items_[read]); + this->items_[write] = this->items_[read]; } ++write; } else { - this->recycle_item_main_loop_(std::move(this->items_[read])); + this->recycle_item_main_loop_(this->items_[read]); } } this->items_.erase(this->items_.begin() + write, this->items_.end()); @@ -444,7 +439,7 @@ void Scheduler::compact_defer_queue_locked_() { // and recycled on the next loop iteration. size_t remaining = this->defer_queue_.size() - this->defer_queue_front_; for (size_t i = 0; i < remaining; i++) { - this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); + this->defer_queue_[i] = this->defer_queue_[this->defer_queue_front_ + i]; } // Use erase() instead of resize() to avoid instantiating _M_default_append // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. @@ -469,26 +464,26 @@ void HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; - std::vector old_items; + std::vector old_items; ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_.size(), now_64); // Cleanup before debug output this->cleanup_(); while (!this->items_.empty()) { - SchedulerItemPtr item; + SchedulerItem *item; { LockGuard guard{this->lock_}; item = this->pop_raw_locked_(); } SchedulerNameLog name_log; - bool is_cancelled = is_item_removed_(item.get()); + bool is_cancelled = is_item_removed_(item); ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", item->get_type_str(), LOG_STR_ARG(item->get_source()), name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval, item->get_next_execution() - now_64, item->get_next_execution(), is_cancelled ? " [CANCELLED]" : ""); - old_items.push_back(std::move(item)); + old_items.push_back(item); } ESP_LOGD(TAG, "\n"); @@ -512,7 +507,7 @@ void HOT Scheduler::call(uint32_t now) { } while (!this->items_.empty()) { // Don't copy-by value yet - auto &item = this->items_[0]; + SchedulerItem *item = this->items_[0]; if (item->get_next_execution() > now_64) { // Not reached timeout yet, done for this call break; @@ -532,7 +527,7 @@ void HOT Scheduler::call(uint32_t now) { // Multi-threaded platforms without atomics: must take lock to safely read remove flag { LockGuard guard{this->lock_}; - if (is_item_removed_locked_(item.get())) { + if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; @@ -540,7 +535,7 @@ void HOT Scheduler::call(uint32_t now) { } #else // Single-threaded or multi-threaded with atomics: can check without lock - if (is_item_removed_(item.get())) { + if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; @@ -561,18 +556,18 @@ void HOT Scheduler::call(uint32_t now) { // Warning: During callback(), a lot of stuff can happen, including: // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled - now = this->execute_item_(item.get(), now); + now = this->execute_item_(item, now); LockGuard guard{this->lock_}; // Only pop after function call, this ensures we were reachable // during the function call and know if we were cancelled. - auto executed_item = this->pop_raw_locked_(); + SchedulerItem *executed_item = this->pop_raw_locked_(); - if (this->is_item_removed_locked_(executed_item.get())) { + if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; - this->recycle_item_main_loop_(std::move(executed_item)); + this->recycle_item_main_loop_(executed_item); continue; } @@ -580,10 +575,10 @@ void HOT Scheduler::call(uint32_t now) { executed_item->set_next_execution(now_64 + executed_item->interval); // Add new item directly to to_add_ // since we have the lock held - this->to_add_.push_back(std::move(executed_item)); + this->to_add_.push_back(executed_item); } else { // Timeout completed - recycle it - this->recycle_item_main_loop_(std::move(executed_item)); + this->recycle_item_main_loop_(executed_item); } has_added_items |= !this->to_add_.empty(); @@ -592,17 +587,33 @@ void HOT Scheduler::call(uint32_t now) { if (has_added_items) { this->process_to_add(); } + +#ifdef ESPHOME_DEBUG_SCHEDULER + // Verify no items were leaked during this call() cycle. + // All items must be in items_, to_add_, defer_queue_, or the pool. + // Safe to check here because: + // - process_defer_queue_ has already run its cleanup_defer_queue_locked_(), + // so defer_queue_ contains no nullptr slots inflating the count. + // - The while loop above has finished, so no items are held in local variables; + // every item has been returned to a container (items_, to_add_, or pool). + // Lock needed to get a consistent snapshot of all containers. + { + LockGuard guard{this->lock_}; + this->debug_verify_no_leak_(); + } +#endif } void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; - for (auto &it : this->to_add_) { - if (is_item_removed_locked_(it.get())) { + for (auto *&it : this->to_add_) { + if (is_item_removed_locked_(it)) { // Recycle cancelled items - this->recycle_item_main_loop_(std::move(it)); + this->recycle_item_main_loop_(it); + it = nullptr; continue; } - this->items_.push_back(std::move(it)); + this->items_.push_back(it); std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); @@ -628,20 +639,18 @@ size_t HOT Scheduler::cleanup_() { // leading to race conditions LockGuard guard{this->lock_}; while (!this->items_.empty()) { - auto &item = this->items_[0]; - if (!this->is_item_removed_locked_(item.get())) + SchedulerItem *item = this->items_[0]; + if (!this->is_item_removed_locked_(item)) break; this->to_remove_--; this->recycle_item_main_loop_(this->pop_raw_locked_()); } return this->items_.size(); } -Scheduler::SchedulerItemPtr HOT Scheduler::pop_raw_locked_() { +Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - // Move the item out before popping - this is the item that was at the front of the heap - auto item = std::move(this->items_.back()); - + SchedulerItem *item = this->items_.back(); this->items_.pop_back(); return item; } @@ -699,7 +708,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type return total_cancelled > 0; } -bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b) { +bool HOT Scheduler::SchedulerItem::cmp(SchedulerItem *a, SchedulerItem *b) { // High bits are almost always equal (change only on 32-bit rollover ~49 days) // Optimize for common case: check low bits first when high bits are equal return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_) @@ -710,23 +719,26 @@ bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const Schedule // IMPORTANT: Caller must hold the scheduler lock before calling this function. // This protects scheduler_item_pool_ from concurrent access by other threads // that may be acquiring items from the pool in set_timer_common_(). -void Scheduler::recycle_item_main_loop_(SchedulerItemPtr item) { - if (!item) +void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { + if (item == nullptr) return; if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { // Clear callback to release captured resources item->callback = nullptr; - this->scheduler_item_pool_.push_back(std::move(item)); + this->scheduler_item_pool_.push_back(item); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif } else { #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); +#endif + delete item; +#ifdef ESPHOME_DEBUG_SCHEDULER + this->debug_live_items_--; #endif } - // else: unique_ptr will delete the item when it goes out of scope } #ifdef ESPHOME_DEBUG_SCHEDULER @@ -753,21 +765,54 @@ void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, // Helper to get or create a scheduler item from the pool // IMPORTANT: Caller must hold the scheduler lock before calling this function. -Scheduler::SchedulerItemPtr Scheduler::get_item_from_pool_locked_() { - SchedulerItemPtr item; +Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() { if (!this->scheduler_item_pool_.empty()) { - item = std::move(this->scheduler_item_pool_.back()); + SchedulerItem *item = this->scheduler_item_pool_.back(); this->scheduler_item_pool_.pop_back(); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif - } else { - item = SchedulerItemPtr(new SchedulerItem()); -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Allocated new item (pool empty)"); -#endif + return item; } +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Allocated new item (pool empty)"); +#endif + auto *item = new SchedulerItem(); +#ifdef ESPHOME_DEBUG_SCHEDULER + this->debug_live_items_++; +#endif return item; } +#ifdef ESPHOME_DEBUG_SCHEDULER +bool Scheduler::debug_verify_no_leak_() const { + // Invariant: every live SchedulerItem must be in exactly one container. + // debug_live_items_ tracks allocations minus deletions. + size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_.size(); +#ifndef ESPHOME_THREAD_SINGLE + accounted += this->defer_queue_.size(); +#endif + if (accounted != this->debug_live_items_) { + ESP_LOGE(TAG, + "SCHEDULER LEAK DETECTED: live=%" PRIu32 " but accounted=%" PRIu32 " (items=%" PRIu32 " to_add=%" PRIu32 + " pool=%" PRIu32 +#ifndef ESPHOME_THREAD_SINGLE + " defer=%" PRIu32 +#endif + ")", + static_cast(this->debug_live_items_), static_cast(accounted), + static_cast(this->items_.size()), static_cast(this->to_add_.size()), + static_cast(this->scheduler_item_pool_.size()) +#ifndef ESPHOME_THREAD_SINGLE + , + static_cast(this->defer_queue_.size()) +#endif + ); + assert(false); + return false; + } + return true; +} +#endif + } // namespace esphome diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index eb6cea4f37d..0476513bb99 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #include -#include #include #include #ifdef ESPHOME_THREAD_MULTI_ATOMICS @@ -144,19 +143,6 @@ class Scheduler { }; protected: - struct SchedulerItem; - - // Custom deleter for SchedulerItem unique_ptr that prevents the compiler from - // inlining the destructor at every destruction site. On BK7231N (Thumb-1), GCC - // inlines ~unique_ptr (~30 bytes: null check + ~std::function + - // operator delete) at every destruction site, while ESP32/ESP8266/RTL8720CF outline - // it into a single helper. This noinline deleter ensures only one copy exists. - // operator() is defined in scheduler.cpp to prevent inlining. - struct SchedulerItemDeleter { - void operator()(SchedulerItem *ptr) const noexcept; - }; - using SchedulerItemPtr = std::unique_ptr; - struct SchedulerItem { // Ordered by size to minimize padding Component *component; @@ -219,14 +205,14 @@ class Scheduler { name_.static_name = nullptr; } - // Destructor - no dynamic memory to clean up + // Destructor - no dynamic memory to clean up (callback's std::function handles its own) ~SchedulerItem() = default; // Delete copy operations to prevent accidental copies SchedulerItem(const SchedulerItem &) = delete; SchedulerItem &operator=(const SchedulerItem &) = delete; - // Delete move operations: SchedulerItem objects are only managed via unique_ptr, never moved directly + // Delete move operations: SchedulerItem objects are managed via raw pointers, never moved directly SchedulerItem(SchedulerItem &&) = delete; SchedulerItem &operator=(SchedulerItem &&) = delete; @@ -250,7 +236,7 @@ class Scheduler { name_type_ = type; } - static bool cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b); + static bool cmp(SchedulerItem *a, SchedulerItem *b); // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. // The upper 16 bits of the 64-bit value are always zero, which is fine since @@ -301,12 +287,13 @@ class Scheduler { // Returns the number of items remaining after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). size_t cleanup_(); - // Remove and return the front item from the heap + // Remove and return the front item from the heap as a raw pointer. + // Caller takes ownership and must either recycle or delete the item. // IMPORTANT: Caller must hold the scheduler lock before calling this function. - SchedulerItemPtr pop_raw_locked_(); + SchedulerItem *pop_raw_locked_(); // Get or create a scheduler item from the pool // IMPORTANT: Caller must hold the scheduler lock before calling this function. - SchedulerItemPtr get_item_from_pool_locked_(); + SchedulerItem *get_item_from_pool_locked_(); private: // Helper to cancel items - must be called with lock held @@ -330,19 +317,16 @@ class Scheduler { // Helper function to check if item matches criteria for cancellation // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // IMPORTANT: Must be called with scheduler lock held - inline bool HOT matches_item_locked_(const SchedulerItemPtr &item, Component *component, NameType name_type, + inline bool HOT matches_item_locked_(SchedulerItem *item, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded - // platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries. - // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_()), but this check - // provides defense-in-depth: helper - // functions should be safe regardless of caller behavior. + // platforms, items can be nulled in defer_queue_ during processing. // Fixes: https://github.com/esphome/esphome/issues/11940 - if (!item) + if (item == nullptr) return false; - if (item->component != component || item->type != type || - (skip_removed && this->is_item_removed_locked_(item.get())) || (match_retry && !item->is_retry)) { + if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || + (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -364,10 +348,12 @@ class Scheduler { } // Helper to recycle a SchedulerItem back to the pool. + // Takes a raw pointer — caller transfers ownership. The item is either added to the + // pool or deleted if the pool is full. // IMPORTANT: Only call from main loop context! Recycling clears the callback, // so calling from another thread while the callback is executing causes use-after-free. // IMPORTANT: Caller must hold the scheduler lock before calling this function. - void recycle_item_main_loop_(SchedulerItemPtr item); + void recycle_item_main_loop_(SchedulerItem *item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -423,27 +409,28 @@ class Scheduler { // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), // recycle each item after re-acquiring the lock for the next iteration (N+1 total). // The lock is held across: recycle → loop condition → move-out, then released for execution. - SchedulerItemPtr item; + SchedulerItem *item; this->lock_.lock(); while (this->defer_queue_front_ < defer_queue_end) { - // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. - // This is intentional and safe because: + // Take ownership of the item, leaving nullptr in the vector slot. + // This is safe because: // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_) // 3. The lock protects concurrent access, but the nullptr remains until cleanup - item = std::move(this->defer_queue_[this->defer_queue_front_]); + item = this->defer_queue_[this->defer_queue_front_]; + this->defer_queue_[this->defer_queue_front_] = nullptr; this->defer_queue_front_++; this->lock_.unlock(); // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again - if (!this->should_skip_item_(item.get())) { - now = this->execute_item_(item.get(), now); + if (!this->should_skip_item_(item)) { + now = this->execute_item_(item, now); } this->lock_.lock(); - this->recycle_item_main_loop_(std::move(item)); + this->recycle_item_main_loop_(item); } // Clean up the queue (lock already held from last recycle or initial acquisition) this->cleanup_defer_queue_locked_(); @@ -523,18 +510,14 @@ class Scheduler { // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // Returns the number of items marked for removal // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, + __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) { size_t count = 0; - for (auto &item : container) { - // Skip nullptr items (can happen in defer_queue_ when items are being processed) - // The defer_queue_ uses index-based processing: items are std::moved out but left in the - // vector as nullptr until cleanup. Even though this function is called with lock held, - // the vector can still contain nullptr items from the processing loop. This check prevents crashes. - if (item && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { - this->set_item_removed_(item.get(), true); + for (auto *item : container) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + this->set_item_removed_(item, true); count++; } } @@ -542,15 +525,15 @@ class Scheduler { } Mutex lock_; - std::vector items_; - std::vector to_add_; + std::vector items_; + std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE // Single-core platforms don't need the defer queue and save ~32 bytes of RAM // Using std::vector instead of std::deque avoids 512-byte chunked allocations // Index tracking avoids O(n) erase() calls when draining the queue each loop - std::vector defer_queue_; // FIFO queue for defer() calls - size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) -#endif /* ESPHOME_THREAD_SINGLE */ + std::vector defer_queue_; // FIFO queue for defer() calls + size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) +#endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; // Memory pool for recycling SchedulerItem objects to reduce heap churn. @@ -561,7 +544,18 @@ class Scheduler { // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation // can stall the entire system, causing timing issues and dropped events for any components that need // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) - std::vector scheduler_item_pool_; + std::vector scheduler_item_pool_; + +#ifdef ESPHOME_DEBUG_SCHEDULER + // Leak detection: tracks total live SchedulerItem allocations. + // Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_.size() + // Verified periodically in call() to catch leaks early. + size_t debug_live_items_{0}; + + // Verify the scheduler memory invariant: all allocated items are accounted for. + // Returns true if no leak detected. Logs an error and asserts on failure. + bool debug_verify_no_leak_() const; +#endif }; } // namespace esphome diff --git a/tests/integration/fixtures/scheduler_bulk_cleanup.yaml b/tests/integration/fixtures/scheduler_bulk_cleanup.yaml index de876da8c47..3d2c47a0de5 100644 --- a/tests/integration/fixtures/scheduler_bulk_cleanup.yaml +++ b/tests/integration/fixtures/scheduler_bulk_cleanup.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-bulk-cleanup external_components: diff --git a/tests/integration/fixtures/scheduler_defer_cancel.yaml b/tests/integration/fixtures/scheduler_defer_cancel.yaml index 9e3f927c33c..92ae0062aca 100644 --- a/tests/integration/fixtures/scheduler_defer_cancel.yaml +++ b/tests/integration/fixtures/scheduler_defer_cancel.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-cancel host: diff --git a/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml b/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml index fb6b1791dc4..cf7f6ec7338 100644 --- a/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml +++ b/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-cancel-regular host: diff --git a/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml b/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml index 7384082ac2d..f69e5c6c67b 100644 --- a/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml +++ b/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-fifo-simple host: diff --git a/tests/integration/fixtures/scheduler_defer_stress.yaml b/tests/integration/fixtures/scheduler_defer_stress.yaml index 0d9c1d14051..70eac01daf6 100644 --- a/tests/integration/fixtures/scheduler_defer_stress.yaml +++ b/tests/integration/fixtures/scheduler_defer_stress.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-stress-test external_components: diff --git a/tests/integration/fixtures/scheduler_heap_stress.yaml b/tests/integration/fixtures/scheduler_heap_stress.yaml index d4d340b68ba..486a5d12764 100644 --- a/tests/integration/fixtures/scheduler_heap_stress.yaml +++ b/tests/integration/fixtures/scheduler_heap_stress.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-heap-stress-test external_components: diff --git a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml index 46dbb8e728d..e696e99efa6 100644 --- a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml +++ b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-internal-id-test on_boot: priority: -100 diff --git a/tests/integration/fixtures/scheduler_null_name.yaml b/tests/integration/fixtures/scheduler_null_name.yaml index 42eaacdd439..d5488761d68 100644 --- a/tests/integration/fixtures/scheduler_null_name.yaml +++ b/tests/integration/fixtures/scheduler_null_name.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-null-name host: diff --git a/tests/integration/fixtures/scheduler_numeric_id_test.yaml b/tests/integration/fixtures/scheduler_numeric_id_test.yaml index 1669f026f5c..25decf20f51 100644 --- a/tests/integration/fixtures/scheduler_numeric_id_test.yaml +++ b/tests/integration/fixtures/scheduler_numeric_id_test.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-numeric-id-test on_boot: priority: -100 diff --git a/tests/integration/fixtures/scheduler_rapid_cancellation.yaml b/tests/integration/fixtures/scheduler_rapid_cancellation.yaml index 4824654c5c6..530b8241f58 100644 --- a/tests/integration/fixtures/scheduler_rapid_cancellation.yaml +++ b/tests/integration/fixtures/scheduler_rapid_cancellation.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-rapid-cancel-test external_components: diff --git a/tests/integration/fixtures/scheduler_recursive_timeout.yaml b/tests/integration/fixtures/scheduler_recursive_timeout.yaml index f1168802f6e..66b6f4b19bb 100644 --- a/tests/integration/fixtures/scheduler_recursive_timeout.yaml +++ b/tests/integration/fixtures/scheduler_recursive_timeout.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-recursive-timeout external_components: diff --git a/tests/integration/fixtures/scheduler_removed_item_race.yaml b/tests/integration/fixtures/scheduler_removed_item_race.yaml index 2f8a7fb987b..55d2197d7ce 100644 --- a/tests/integration/fixtures/scheduler_removed_item_race.yaml +++ b/tests/integration/fixtures/scheduler_removed_item_race.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-removed-item-race host: diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml index ffe9082a69f..cdf71152bdc 100644 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ b/tests/integration/fixtures/scheduler_retry_test.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-retry-test on_boot: priority: -100 diff --git a/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml b/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml index 446ee7fdc0e..c15edc3ffd5 100644 --- a/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml +++ b/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-simul-callbacks-test external_components: diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml index ebd5052b8bf..5ae5a1914e7 100644 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ b/tests/integration/fixtures/scheduler_string_lifetime.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-string-lifetime-test external_components: diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml index d1ef55c8d5f..8f68d1d1023 100644 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ b/tests/integration/fixtures/scheduler_string_name_stress.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-string-name-stress external_components: From 89bb5d9e42fcc911a73af225af78390a96ad8e5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:11:45 -1000 Subject: [PATCH 08/44] [core] Require explicit synchronous= for register_action (#14606) --- esphome/automation.py | 38 ++++++++++--- esphome/components/ags10/sensor.py | 2 + esphome/components/aic3204/audio_dac.py | 5 +- .../alarm_control_panel/__init__.py | 40 +++++++++++--- esphome/components/animation/__init__.py | 12 +++-- esphome/components/api/__init__.py | 1 + esphome/components/at581x/__init__.py | 2 + esphome/components/audio_adc/__init__.py | 5 +- esphome/components/audio_dac/__init__.py | 13 +++-- esphome/components/binary_sensor/__init__.py | 1 + esphome/components/bl0906/sensor.py | 1 + esphome/components/ble_client/__init__.py | 22 ++++++-- esphome/components/bm8563/time.py | 3 ++ esphome/components/canbus/__init__.py | 1 + esphome/components/cc1101/__init__.py | 27 +++++++--- esphome/components/climate/__init__.py | 5 +- esphome/components/cm1106/sensor.py | 1 + esphome/components/cs5460a/sensor.py | 1 + esphome/components/datetime/__init__.py | 3 ++ esphome/components/deep_sleep/__init__.py | 7 ++- esphome/components/dfplayer/__init__.py | 16 ++++++ .../components/dfrobot_sen0395/__init__.py | 2 + esphome/components/display/__init__.py | 3 ++ .../components/display_menu_base/__init__.py | 33 +++++++++--- esphome/components/ds1307/time.py | 2 + esphome/components/duty_time/sensor.py | 12 +++-- esphome/components/esp32_ble/__init__.py | 8 ++- .../components/esp32_ble_server/__init__.py | 3 ++ .../components/esp32_ble_tracker/__init__.py | 2 + esphome/components/esp8266_pwm/output.py | 1 + esphome/components/esp_ldo/__init__.py | 1 + esphome/components/espnow/__init__.py | 5 ++ esphome/components/event/__init__.py | 4 +- esphome/components/ezo_pmp/__init__.py | 28 ++++++++-- esphome/components/fan/__init__.py | 1 + .../components/fingerprint_grow/__init__.py | 6 +++ .../components/grove_tb6612fng/__init__.py | 6 +++ esphome/components/haier/climate.py | 49 +++++++++++++---- esphome/components/hbridge/fan/__init__.py | 1 + esphome/components/hc8/sensor.py | 5 +- esphome/components/hdc302x/sensor.py | 10 +++- esphome/components/hlk_fm22x/__init__.py | 5 ++ esphome/components/http_request/__init__.py | 15 ++++-- .../components/http_request/ota/__init__.py | 1 + esphome/components/htu21d/sensor.py | 2 + esphome/components/hub75/display.py | 1 + esphome/components/integration/sensor.py | 2 + esphome/components/key_collector/__init__.py | 2 + esphome/components/ld2410/__init__.py | 5 +- esphome/components/ledc/output.py | 1 + esphome/components/libretiny_pwm/output.py | 1 + esphome/components/light/automation.py | 5 +- esphome/components/lightwaverf/__init__.py | 1 + esphome/components/lock/__init__.py | 12 +++-- esphome/components/logger/__init__.py | 1 + esphome/components/lvgl/automation.py | 27 ++++++++-- esphome/components/lvgl/styles.py | 1 + esphome/components/lvgl/types.py | 1 + esphome/components/lvgl/widgets/animimg.py | 2 + .../components/lvgl/widgets/buttonmatrix.py | 1 + esphome/components/lvgl/widgets/canvas.py | 8 +++ esphome/components/lvgl/widgets/meter.py | 1 + esphome/components/lvgl/widgets/page.py | 3 ++ esphome/components/lvgl/widgets/spinbox.py | 2 + esphome/components/lvgl/widgets/tabview.py | 1 + esphome/components/lvgl/widgets/tileview.py | 1 + esphome/components/max17043/sensor.py | 4 +- esphome/components/max6956/__init__.py | 2 + esphome/components/max7219digit/display.py | 35 +++++++++--- esphome/components/media_player/__init__.py | 7 ++- esphome/components/mhz19/sensor.py | 20 +++++-- .../components/micro_wake_word/__init__.py | 13 ++++- esphome/components/microphone/__init__.py | 22 +++++--- esphome/components/midea/climate.py | 4 +- esphome/components/mixer/speaker/__init__.py | 1 + esphome/components/mqtt/__init__.py | 2 + esphome/components/nau7802/sensor.py | 3 ++ .../nextion/binary_sensor/__init__.py | 1 + esphome/components/nextion/display.py | 1 + esphome/components/nextion/sensor/__init__.py | 1 + esphome/components/nextion/switch/__init__.py | 1 + .../nextion/text_sensor/__init__.py | 1 + esphome/components/online_image/__init__.py | 9 +++- esphome/components/output/__init__.py | 2 + esphome/components/pcf85063/time.py | 2 + esphome/components/pcf8563/time.py | 2 + esphome/components/pid/climate.py | 3 ++ .../components/pipsolar/output/__init__.py | 1 + esphome/components/pmwcs3/sensor.py | 3 ++ esphome/components/pn7150/__init__.py | 43 +++++++++++---- esphome/components/pn7160/__init__.py | 43 +++++++++++---- esphome/components/pulse_counter/sensor.py | 1 + esphome/components/pulse_meter/sensor.py | 1 + esphome/components/pzemac/sensor.py | 1 + esphome/components/pzemdc/sensor.py | 1 + esphome/components/remote_base/__init__.py | 5 +- .../components/remote_transmitter/__init__.py | 5 +- esphome/components/rf_bridge/__init__.py | 22 ++++++-- esphome/components/rotary_encoder/sensor.py | 1 + esphome/components/rp2040_pwm/output.py | 1 + esphome/components/rtttl/__init__.py | 2 + esphome/components/rx8130/time.py | 2 + esphome/components/safe_mode/__init__.py | 1 + esphome/components/scd30/sensor.py | 1 + esphome/components/scd4x/sensor.py | 6 ++- esphome/components/script/__init__.py | 1 + esphome/components/sen5x/sensor.py | 5 +- esphome/components/senseair/sensor.py | 17 ++++-- esphome/components/servo/__init__.py | 2 + esphome/components/sim800l/__init__.py | 16 ++++-- esphome/components/sound_level/sensor.py | 8 ++- esphome/components/speaker/__init__.py | 23 +++++--- .../speaker/media_player/__init__.py | 1 + esphome/components/sprinkler/__init__.py | 53 +++++++++++++++---- esphome/components/sps30/sensor.py | 15 ++++-- esphome/components/stepper/__init__.py | 5 ++ esphome/components/sx126x/__init__.py | 30 ++++++++--- esphome/components/sx127x/__init__.py | 30 ++++++++--- .../template/binary_sensor/__init__.py | 1 + esphome/components/template/cover/__init__.py | 1 + esphome/components/template/lock/__init__.py | 1 + .../components/template/sensor/__init__.py | 1 + .../components/template/switch/__init__.py | 1 + .../template/text_sensor/__init__.py | 1 + esphome/components/template/valve/__init__.py | 1 + .../template/water_heater/__init__.py | 1 + esphome/components/tm1651/__init__.py | 12 ++++- esphome/components/uart/__init__.py | 1 + esphome/components/udp/__init__.py | 1 + esphome/components/ufire_ec/sensor.py | 2 + esphome/components/ufire_ise/sensor.py | 3 ++ esphome/components/update/__init__.py | 2 + esphome/components/valve/__init__.py | 20 +++++-- .../components/voice_assistant/__init__.py | 6 ++- esphome/components/wifi/__init__.py | 9 +++- esphome/components/wireguard/__init__.py | 2 + esphome/components/zigbee/__init__.py | 1 + 137 files changed, 852 insertions(+), 187 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index d9b8b2ec574..36ab30b654a 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,3 +1,5 @@ +import logging + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -57,22 +59,41 @@ def maybe_conf(conf, *validators): return validate +_LOGGER = logging.getLogger(__name__) + + def register_action( name: str, action_type: MockObjClass, schema: cv.Schema, *, - synchronous: bool = False, + synchronous: bool | None = None, ): """Register an action type. - Actions default to ``synchronous=False`` (safe default), meaning string - arguments use owning std::string to prevent dangling references. + All callers must pass ``synchronous`` explicitly. - Set ``synchronous=True`` only for actions that complete synchronously - and never store trigger arguments for later execution. This allows - the code generator to use non-owning StringRef for zero-copy access. + ``synchronous=True`` — the action never defers ``play_next_()`` to a + later point (callback, timer, or ``loop()``). Trigger arguments are + only used during the initial call, so string args can use non-owning + StringRef for zero-copy access. + + ``synchronous=False`` — the action defers ``play_next_()`` via a + callback, timer, or ``Component::loop()``. Trigger arguments must + outlive the initial call, so string args use owning std::string to + prevent dangling references. """ + if synchronous is None: + _LOGGER.warning( + "register_action('%s', ...) is missing the synchronous= parameter. " + "Defaulting to synchronous=False (safe but prevents StringRef " + "optimization). Check the C++ class: use synchronous=False if " + "play_next_() is deferred to a callback, timer, or loop(); " + "use synchronous=True if play_next_() always runs before the " + "initial play/play_complex call returns", + name, + ) + synchronous = False return ACTION_REGISTRY.register(name, action_type, schema, synchronous=synchronous) @@ -353,6 +374,7 @@ async def component_is_idle_condition_to_code( "delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds), + synchronous=False, ) async def delay_action_to_code( config: ConfigType, @@ -465,7 +487,7 @@ _validate_wait_until = cv.maybe_simple_value( ) -@register_action("wait_until", WaitUntilAction, _validate_wait_until) +@register_action("wait_until", WaitUntilAction, _validate_wait_until, synchronous=False) async def wait_until_action_to_code( config: ConfigType, action_id: ID, @@ -611,7 +633,7 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef - unsafe. Actions that haven't been audited default to non-synchronous. + unsafe. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8f0f3729516..4cfa9e67ec1 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -92,6 +92,7 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( "ags10.new_i2c_address", AGS10NewI2cAddressAction, AGS10_NEW_I2C_ADDRESS_SCHEMA, + synchronous=True, ) async def ags10newi2caddress_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -121,6 +122,7 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( "ags10.set_zero_point", AGS10SetZeroPointAction, AGS10_SET_ZERO_POINT_SCHEMA, + synchronous=True, ) async def ags10setzeropoint_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index da7a54df54d..a644638f696 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -34,7 +34,10 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "aic3204.set_auto_mute_mode", SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA + "aic3204.set_auto_mute_mode", + SetAutoMuteAction, + SET_AUTO_MUTE_ACTION_SCHEMA, + synchronous=True, ) async def aic3204_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b8555861527..aefb18d25cc 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -243,7 +243,10 @@ async def new_alarm_control_panel(config, *args): @automation.register_action( - "alarm_control_panel.arm_away", ArmAwayAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.arm_away", + ArmAwayAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -255,7 +258,10 @@ async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.arm_home", ArmHomeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.arm_home", + ArmHomeAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -267,7 +273,10 @@ async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.arm_night", ArmNightAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.arm_night", + ArmNightAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -279,7 +288,10 @@ async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.disarm", DisarmAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.disarm", + DisarmAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_disarm_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -291,7 +303,10 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.pending", PendingAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.pending", + PendingAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_pending_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -299,7 +314,10 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.triggered", TriggeredAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.triggered", + TriggeredAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_trigger_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -307,7 +325,10 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.chime", ChimeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.chime", + ChimeAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_chime_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -315,7 +336,10 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.ready", ReadyAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.ready", + ReadyAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) @automation.register_condition( "alarm_control_panel.ready", diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index c4ac7adb236..e9630f5266b 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -69,9 +69,15 @@ SET_FRAME_SCHEMA = cv.Schema( ) -@automation.register_action("animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA) -@automation.register_action("animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA) -@automation.register_action("animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA) +@automation.register_action( + "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +) async def animation_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index c7dec6e78be..9772e6afca7 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -712,6 +712,7 @@ API_RESPOND_ACTION_SCHEMA = cv.All( "api.respond", APIRespondAction, API_RESPOND_ACTION_SCHEMA, + synchronous=True, ) async def api_respond_to_code( config: ConfigType, diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 117ada123db..0780814ea6e 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -89,6 +89,7 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio cv.Required(CONF_ID): cv.use_id(AT581XComponent), } ), + synchronous=True, ) async def at581x_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -160,6 +161,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( "at581x.settings", AT581XSettingsAction, RADAR_SETTINGS_SCHEMA, + synchronous=True, ) async def at581x_settings_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 2f95a039f5c..3c9b32e6107 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -23,7 +23,10 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "audio_adc.set_mic_gain", SetMicGainAction, SET_MIC_GAIN_ACTION_SCHEMA + "audio_adc.set_mic_gain", + SetMicGainAction, + SET_MIC_GAIN_ACTION_SCHEMA, + synchronous=True, ) async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 92e6cb18fa0..a950c1967bf 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -31,15 +31,22 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action("audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA) -@automation.register_action("audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA) +@automation.register_action( + "audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True +) async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( - "audio_dac.set_volume", SetVolumeAction, SET_VOLUME_ACTION_SCHEMA + "audio_dac.set_volume", + SetVolumeAction, + SET_VOLUME_ACTION_SCHEMA, + synchronous=True, ) async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1f641185602..37cccc01be6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -685,6 +685,7 @@ async def to_code(config): }, key=CONF_ID, ), + synchronous=True, ) async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 42c6f06092b..059e10e962d 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -143,6 +143,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( cv.Required(CONF_ID): cv.use_id(BL0906), } ), + synchronous=True, ) async def reset_energy_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 37db181584b..56ac2ea1472 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -172,7 +172,10 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "ble_client.disconnect", BLEDisconnectAction, BLE_CONNECT_ACTION_SCHEMA + "ble_client.disconnect", + BLEDisconnectAction, + BLE_CONNECT_ACTION_SCHEMA, + synchronous=False, ) async def ble_disconnect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -180,7 +183,10 @@ async def ble_disconnect_to_code(config, action_id, template_arg, args): @automation.register_action( - "ble_client.connect", BLEConnectAction, BLE_CONNECT_ACTION_SCHEMA + "ble_client.connect", + BLEConnectAction, + BLE_CONNECT_ACTION_SCHEMA, + synchronous=False, ) async def ble_connect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -188,7 +194,10 @@ async def ble_connect_to_code(config, action_id, template_arg, args): @automation.register_action( - "ble_client.ble_write", BLEWriteAction, BLE_WRITE_ACTION_SCHEMA + "ble_client.ble_write", + BLEWriteAction, + BLE_WRITE_ACTION_SCHEMA, + synchronous=False, ) async def ble_write_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -247,6 +256,7 @@ async def ble_write_to_code(config, action_id, template_arg, args): "ble_client.numeric_comparison_reply", BLENumericComparisonReplyAction, BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, + synchronous=True, ) async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -263,7 +273,10 @@ async def numeric_comparison_reply_to_code(config, action_id, template_arg, args @automation.register_action( - "ble_client.passkey_reply", BLEPasskeyReplyAction, BLE_PASSKEY_REPLY_ACTION_SCHEMA + "ble_client.passkey_reply", + BLEPasskeyReplyAction, + BLE_PASSKEY_REPLY_ACTION_SCHEMA, + synchronous=True, ) async def passkey_reply_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -283,6 +296,7 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): "ble_client.remove_bond", BLERemoveBondAction, BLE_REMOVE_BOND_ACTION_SCHEMA, + synchronous=True, ) async def remove_bond_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 2785315af27..ba264f00bfe 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -33,6 +33,7 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.use_id(BM8563), } ), + synchronous=True, ) async def bm8563_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -49,6 +50,7 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args): cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), } ), + synchronous=True, ) async def bm8563_start_timer_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -66,6 +68,7 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(BM8563), } ), + synchronous=True, ) async def bm8563_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 7b51c2c45c1..c94c8647a95 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -155,6 +155,7 @@ async def register_canbus(var, config): validate_id, key=CONF_DATA, ), + synchronous=True, ) async def canbus_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index e2e5986daff..27092908621 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -287,10 +287,18 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action("cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA) -@automation.register_action("cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA) -@automation.register_action("cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA) -@automation.register_action("cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA) +@automation.register_action( + "cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA, synchronous=True +) async def cc1101_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -317,7 +325,10 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA + "cc1101.send_packet", + SendPacketAction, + SEND_PACKET_ACTION_SCHEMA, + synchronous=True, ) async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -419,9 +430,9 @@ def _register_setter_actions(): cg.add(getattr(var, _setter)(_map[data] if _map else data)) return var - automation.register_action(f"cc1101.{setter_name}", action_cls, schema)( - _setter_action_to_code - ) + automation.register_action( + f"cc1101.{setter_name}", action_cls, schema, synchronous=True + )(_setter_action_to_code) _register_setter_actions() diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index f5b91c502c7..8cf5fa9b0c2 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -476,7 +476,10 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "climate.control", ControlAction, CLIMATE_CONTROL_ACTION_SCHEMA + "climate.control", + ControlAction, + CLIMATE_CONTROL_ACTION_SCHEMA, + synchronous=True, ) async def climate_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 1d95bcc6665..3c82fac977f 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -65,6 +65,7 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( "cm1106.calibrate_zero", CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: """Service code generation entry point.""" diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 07b5ea1c635..d2383bd01b8 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -132,6 +132,7 @@ async def to_code(config): cv.Required(CONF_ID): cv.use_id(CS5460AComponent), } ), + synchronous=True, ) async def restart_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 74c9d594f75..90835624bf1 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -187,6 +187,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def datetime_date_set_to_code(config, action_id, template_arg, args): action_var = cg.new_Pvariable(action_id, template_arg) @@ -218,6 +219,7 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), } ), + synchronous=True, ) async def datetime_time_set_to_code(config, action_id, template_arg, args): action_var = cg.new_Pvariable(action_id, template_arg) @@ -249,6 +251,7 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), }, ), + synchronous=True, ) async def datetime_datetime_set_to_code(config, action_id, template_arg, args): action_var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 3cfe7aa6417..4098fd3fb8b 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -405,7 +405,10 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( @automation.register_action( - "deep_sleep.enter", EnterDeepSleepAction, DEEP_SLEEP_ENTER_SCHEMA + "deep_sleep.enter", + EnterDeepSleepAction, + DEEP_SLEEP_ENTER_SCHEMA, + synchronous=True, ) async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -428,11 +431,13 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): "deep_sleep.prevent", PreventDeepSleepAction, automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + synchronous=True, ) @automation.register_action( "deep_sleep.allow", AllowDeepSleepAction, automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + synchronous=True, ) async def deep_sleep_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 53ebda6bcc8..9df108c9c0a 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -91,6 +91,7 @@ async def to_code(config): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_next_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -106,6 +107,7 @@ async def dfplayer_next_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_previous_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -123,6 +125,7 @@ async def dfplayer_previous_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), + synchronous=True, ) async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -143,6 +146,7 @@ async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), + synchronous=True, ) async def dfplayer_play_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -166,6 +170,7 @@ async def dfplayer_play_to_code(config, action_id, template_arg, args): cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -191,6 +196,7 @@ async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): }, key=CONF_DEVICE, ), + synchronous=True, ) async def dfplayer_set_device_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -210,6 +216,7 @@ async def dfplayer_set_device_to_code(config, action_id, template_arg, args): }, key=CONF_VOLUME, ), + synchronous=True, ) async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -227,6 +234,7 @@ async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -242,6 +250,7 @@ async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -259,6 +268,7 @@ async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): }, key=CONF_EQ_PRESET, ), + synchronous=True, ) async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -276,6 +286,7 @@ async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_sleep_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -291,6 +302,7 @@ async def dfplayer_sleep_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -306,6 +318,7 @@ async def dfplayer_reset_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_start_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -321,6 +334,7 @@ async def dfplayer_start_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_pause_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -336,6 +350,7 @@ async def dfplayer_pause_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -351,6 +366,7 @@ async def dfplayer_stop_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_random_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index d54b147036e..ba77e56abb0 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -52,6 +52,7 @@ async def to_code(config): cv.GenerateID(): cv.use_id(DfrobotSen0395Component), } ), + synchronous=True, ) async def dfrobot_sen0395_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -151,6 +152,7 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema( "dfrobot_sen0395.settings", DfrobotSen0395SettingsAction, MMWAVE_SETTINGS_SCHEMA, + synchronous=True, ) async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 695e7cde476..6367f88acc4 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -159,6 +159,7 @@ async def register_display(var, config): cv.Required(CONF_ID): cv.templatable(cv.use_id(DisplayPage)), } ), + synchronous=True, ) async def display_page_show_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -179,6 +180,7 @@ async def display_page_show_to_code(config, action_id, template_arg, args): cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), } ), + synchronous=True, ) async def display_page_show_next_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -193,6 +195,7 @@ async def display_page_show_next_to_code(config, action_id, template_arg, args): cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), } ), + synchronous=True, ) async def display_page_show_previous_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 658292ec7a3..c9a0c7ee93e 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -294,50 +294,67 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("display_menu.up", UpAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.up", UpAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_up_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.down", DownAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.down", DownAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_down_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.left", LeftAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.left", LeftAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_left_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.right", RightAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.right", RightAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_right_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.enter", EnterAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.enter", EnterAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.show", ShowAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.show", ShowAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_show_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.hide", HideAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.hide", HideAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_hide_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( - "display_menu.show_main", ShowMainAction, MENU_ACTION_SCHEMA + "display_menu.show_main", + ShowMainAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) async def menu_show_main_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 42b7184db9b..0e7bb976a2d 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -27,6 +27,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(DS1307Component), } ), + synchronous=True, ) async def ds1307_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -42,6 +43,7 @@ async def ds1307_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DS1307Component), } ), + synchronous=True, ) async def ds1307_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 1907b3fcfec..456859f8e49 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -90,21 +90,27 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( ) -@register_action("sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA) +@register_action( + "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True +) async def sensor_runtime_start_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -@register_action("sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA) +@register_action( + "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True +) async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -@register_action("sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA) +@register_action( + "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True +) async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 8b368afc2e2..43208eb87eb 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -604,11 +604,15 @@ async def ble_enabled_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg) -@automation.register_action("ble.enable", BLEEnableAction, cv.Schema({})) +@automation.register_action( + "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True +) async def ble_enable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) -@automation.register_action("ble.disable", BLEDisableAction, cv.Schema({})) +@automation.register_action( + "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True +) async def ble_disable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index b08e791e7e3..827ddba9554 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -622,6 +622,7 @@ async def to_code(config): ), validate_set_value_action, ), + synchronous=True, ) async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -641,6 +642,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a cv.Required(CONF_VALUE): value_schema(), } ), + synchronous=True, ) async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -662,6 +664,7 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ), validate_notify_action, ), + synchronous=True, ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 37e74672ed8..c5e8f3178d0 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -373,6 +373,7 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( "esp32_ble_tracker.start_scan", ESP32BLEStartScanAction, ESP32_BLE_START_SCAN_ACTION_SCHEMA, + synchronous=True, ) async def esp32_ble_tracker_start_scan_action_to_code( config, action_id, template_arg, args @@ -396,6 +397,7 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( "esp32_ble_tracker.stop_scan", ESP32BLEStopScanAction, ESP32_BLE_STOP_SCAN_ACTION_SCHEMA, + synchronous=True, ) async def esp32_ble_tracker_stop_scan_action_to_code( config, action_id, template_arg, args diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index a78831c516c..b9b6dcc95a3 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -57,6 +57,7 @@ async def to_code(config) -> None: cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), + synchronous=True, ) async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index 5235a9411ee..a489651b598 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -129,6 +129,7 @@ def adjusted_ldo_id(value): ), } ), + synchronous=True, ) async def ldo_voltage_adjust_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index faeccd910e0..d1a85ae8fd8 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -220,6 +220,7 @@ SEND_SCHEMA.add_extra(_validate_send_action) "espnow.send", SendAction, SEND_SCHEMA, + synchronous=False, ) @automation.register_action( "espnow.broadcast", @@ -232,6 +233,7 @@ SEND_SCHEMA.add_extra(_validate_send_action) ), key=CONF_DATA, ), + synchronous=False, ) async def send_action( config: ConfigType, @@ -271,6 +273,7 @@ async def send_action( PEER_SCHEMA, key=CONF_ADDRESS, ), + synchronous=True, ) @automation.register_action( "espnow.peer.delete", @@ -279,6 +282,7 @@ async def send_action( PEER_SCHEMA, key=CONF_ADDRESS, ), + synchronous=True, ) async def peer_action( config: ConfigType, @@ -303,6 +307,7 @@ async def peer_action( }, key=CONF_CHANNEL, ), + synchronous=True, ) async def channel_action( config: ConfigType, diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 14cc1505ad8..300902b8cad 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -129,7 +129,9 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) -@automation.register_action("event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA) +@automation.register_action( + "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True +) async def event_fire_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index c1f72bb05d5..1538e303f12 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -81,7 +81,10 @@ EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( @automation.register_action( - "ezo_pmp.find", EzoPMPFindAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA + "ezo_pmp.find", + EzoPMPFindAction, + EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_find_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -92,6 +95,7 @@ async def ezo_pmp_find_to_code(config, action_id, template_arg, args): "ezo_pmp.dose_continuously", EzoPMPDoseContinuouslyAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -102,6 +106,7 @@ async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, arg "ezo_pmp.clear_total_volume_dosed", EzoPMPClearTotalVolumeDispensedAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_clear_total_volume_dosed_to_code( config, action_id, template_arg, args @@ -114,6 +119,7 @@ async def ezo_pmp_clear_total_volume_dosed_to_code( "ezo_pmp.clear_calibration", EzoPMPClearCalibrationAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -121,7 +127,10 @@ async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, arg @automation.register_action( - "ezo_pmp.pause_dosing", EzoPMPPauseDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA + "ezo_pmp.pause_dosing", + EzoPMPPauseDosingAction, + EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -129,7 +138,10 @@ async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): @automation.register_action( - "ezo_pmp.stop_dosing", EzoPMPStopDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA + "ezo_pmp.stop_dosing", + EzoPMPStopDosingAction, + EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_stop_dosing_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -149,7 +161,10 @@ EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( @automation.register_action( - "ezo_pmp.dose_volume", EzoPMPDoseVolumeAction, EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA + "ezo_pmp.dose_volume", + EzoPMPDoseVolumeAction, + EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -178,6 +193,7 @@ EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( "ezo_pmp.dose_volume_over_time", EzoPMPDoseVolumeOverTimeAction, EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -209,6 +225,7 @@ EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( "ezo_pmp.dose_with_constant_flow_rate", EzoPMPDoseWithConstantFlowRateAction, EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_with_constant_flow_rate_to_code( config, action_id, template_arg, args @@ -239,6 +256,7 @@ EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( "ezo_pmp.set_calibration_volume", EzoPMPSetCalibrationVolumeAction, EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_set_calibration_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -262,6 +280,7 @@ EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( "ezo_pmp.change_i2c_address", EzoPMPChangeI2CAddressAction, EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -285,6 +304,7 @@ EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( "ezo_pmp.arbitrary_command", EzoPMPArbitraryCommandAction, EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_arbitrary_command_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index da28c577c8e..df71c6ab3fd 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -365,6 +365,7 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): cv.Optional(CONF_OFF_SPEED_CYCLE, default=True): cv.boolean, } ), + synchronous=True, ) async def fan_cycle_speed_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 115b89433bc..2637097be81 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -261,6 +261,7 @@ async def to_code(config): }, key=CONF_FINGER_ID, ), + synchronous=True, ) async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -282,6 +283,7 @@ async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args) cv.GenerateID(): cv.use_id(FingerprintGrowComponent), } ), + synchronous=True, ) async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -299,6 +301,7 @@ async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg }, key=CONF_FINGER_ID, ), + synchronous=True, ) async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -317,6 +320,7 @@ async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args) cv.GenerateID(): cv.use_id(FingerprintGrowComponent), } ), + synchronous=True, ) async def fingerprint_grow_delete_all_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -337,6 +341,7 @@ FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( "fingerprint_grow.led_control", LEDControlAction, FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA, + synchronous=True, ) async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -359,6 +364,7 @@ async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, cv.Required(CONF_COUNT): cv.templatable(cv.uint8_t), } ), + synchronous=True, ) async def fingerprint_grow_aura_led_control_to_code( config, action_id, template_arg, args diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 869c05387ff..210e2f7babb 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -72,6 +72,7 @@ async def to_code(config): cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), + synchronous=True, ) async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -96,6 +97,7 @@ async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), } ), + synchronous=True, ) async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -115,6 +117,7 @@ async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), } ), + synchronous=True, ) async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -133,6 +136,7 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), } ), + synchronous=True, ) async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -149,6 +153,7 @@ async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args) cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), } ), + synchronous=True, ) async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -166,6 +171,7 @@ async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, ar cv.Required(CONF_ADDRESS): cv.i2c_address, } ), + synchronous=True, ) async def grove_tb6612fng_change_address_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 6c208f6caa4..caaaa18dd68 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -319,10 +319,16 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "climate.haier.display_on", DisplayOnAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.display_on", + DisplayOnAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.display_off", DisplayOffAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.display_off", + DisplayOffAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) async def display_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -330,10 +336,16 @@ async def display_action_to_code(config, action_id, template_arg, args): @automation.register_action( - "climate.haier.beeper_on", BeeperOnAction, HAIER_HON_BASE_ACTION_SCHEMA + "climate.haier.beeper_on", + BeeperOnAction, + HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.beeper_off", BeeperOffAction, HAIER_HON_BASE_ACTION_SCHEMA + "climate.haier.beeper_off", + BeeperOffAction, + HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) async def beeper_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -345,11 +357,13 @@ async def beeper_action_to_code(config, action_id, template_arg, args): "climate.haier.start_self_cleaning", StartSelfCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "climate.haier.start_steri_cleaning", StartSteriCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) async def start_cleaning_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -368,6 +382,7 @@ async def start_cleaning_to_code(config, action_id, template_arg, args): ), } ), + synchronous=True, ) async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -391,6 +406,7 @@ async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, ar ), } ), + synchronous=True, ) async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -403,10 +419,16 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, @automation.register_action( - "climate.haier.health_on", HealthOnAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.health_on", + HealthOnAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.health_off", HealthOffAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.health_off", + HealthOffAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) async def health_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -414,13 +436,22 @@ async def health_action_to_code(config, action_id, template_arg, args): @automation.register_action( - "climate.haier.power_on", PowerOnAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.power_on", + PowerOnAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.power_off", PowerOffAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.power_off", + PowerOffAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.power_toggle", PowerToggleAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.power_toggle", + PowerToggleAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) async def power_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 31a20a8981f..8ea8677ba2c 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -52,6 +52,7 @@ CONFIG_SCHEMA = ( "fan.hbridge.brake", BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), + synchronous=True, ) async def fan_hbridge_brake_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 2f39b47f3cd..29b428e3109 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -68,7 +68,10 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "hc8.calibrate", HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA + "hc8.calibrate", + HC8CalibrateAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) async def hc8_calibration_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 7215a4cfb7a..a6265b9b980 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -114,7 +114,10 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "hdc302x.heater_on", HeaterOnAction, HDC302X_HEATER_ON_ACTION_SCHEMA + "hdc302x.heater_on", + HeaterOnAction, + HDC302X_HEATER_ON_ACTION_SCHEMA, + synchronous=True, ) async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -127,7 +130,10 @@ async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): @automation.register_action( - "hdc302x.heater_off", HeaterOffAction, HDC302X_ACTION_SCHEMA + "hdc302x.heater_off", + HeaterOffAction, + HDC302X_ACTION_SCHEMA, + synchronous=True, ) async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index efd64b65139..cb6d5cdfd6d 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -170,6 +170,7 @@ async def to_code(config): }, key=CONF_NAME, ), + synchronous=True, ) async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -192,6 +193,7 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): }, key=CONF_FACE_ID, ), + synchronous=True, ) async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -210,6 +212,7 @@ async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), + synchronous=True, ) async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -225,6 +228,7 @@ async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), + synchronous=True, ) async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -240,6 +244,7 @@ async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), + synchronous=True, ) async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 81337ebdf6e..416432cfc44 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -279,13 +279,22 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( @automation.register_action( - "http_request.get", HttpRequestSendAction, HTTP_REQUEST_GET_ACTION_SCHEMA + "http_request.get", + HttpRequestSendAction, + HTTP_REQUEST_GET_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "http_request.post", HttpRequestSendAction, HTTP_REQUEST_POST_ACTION_SCHEMA + "http_request.post", + HttpRequestSendAction, + HTTP_REQUEST_POST_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "http_request.send", HttpRequestSendAction, HTTP_REQUEST_SEND_ACTION_SCHEMA + "http_request.send", + HttpRequestSendAction, + HTTP_REQUEST_SEND_ACTION_SCHEMA, + synchronous=True, ) async def http_request_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index d2c574d8c61..fb59e51943f 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -70,6 +70,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( "ota.http_request.flash", OtaHttpRequestComponentFlashAction, OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, + synchronous=True, ) async def ota_http_request_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index a578670e375..92c088a22fa 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -93,6 +93,7 @@ async def to_code(config): }, key=CONF_LEVEL, ), + synchronous=True, ) async def set_heater_level_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -112,6 +113,7 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): }, key=CONF_STATUS, ), + synchronous=True, ) async def set_heater_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index f1e6ef42438..4f62ce7a94a 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -652,6 +652,7 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_BRIGHTNESS, ), + synchronous=True, ) async def hub75_set_brightness_to_code( config: ConfigType, diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 26766385565..d0aae4201e4 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -111,6 +111,7 @@ async def to_code(config): cv.Required(CONF_ID): cv.use_id(IntegrationSensor), } ), + synchronous=True, ) async def sensor_integration_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -127,6 +128,7 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), + synchronous=True, ) async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index badb28c32ca..1f4519df2d0 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -142,6 +142,7 @@ async def to_code(config): cv.GenerateID(): cv.use_id(KeyCollector), } ), + synchronous=True, ) async def enable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -157,6 +158,7 @@ async def enable_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(KeyCollector), } ), + synchronous=True, ) async def disable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index b492bbcd149..360e56330af 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -97,7 +97,10 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( @automation.register_action( - "bluetooth_password.set", BluetoothPasswordSetAction, BLUETOOTH_PASSWORD_SET_SCHEMA + "bluetooth_password.set", + BluetoothPasswordSetAction, + BLUETOOTH_PASSWORD_SET_SCHEMA, + synchronous=True, ) async def bluetooth_password_set_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 7a45b9dc3f6..62ff5ad30ac 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -77,6 +77,7 @@ async def to_code(config): cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), + synchronous=True, ) async def ledc_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 28556514d89..e812b6a8f25 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -38,6 +38,7 @@ async def to_code(config): cv.Required(CONF_FREQUENCY): cv.templatable(cv.int_), } ), + synchronous=True, ) async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 08fd26a9379..55273003b95 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -278,7 +278,10 @@ LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "light.addressable_set", AddressableSet, LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA + "light.addressable_set", + AddressableSet, + LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA, + synchronous=True, ) async def light_addressable_set_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 802b341601b..acbbbb4de97 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -55,6 +55,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Any( "lightwaverf.send_raw", LightwaveRawAction, LIGHTWAVE_SEND_SCHEMA, + synchronous=True, ) async def send_raw_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index e37092756fb..fe4db23ae3a 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -129,9 +129,15 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA) -@automation.register_action("lock.lock", LockAction, LOCK_ACTION_SCHEMA) -@automation.register_action("lock.open", OpenAction, LOCK_ACTION_SCHEMA) +@automation.register_action( + "lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "lock.lock", LockAction, LOCK_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True +) async def lock_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 026b8aaf246..83a78541652 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -545,6 +545,7 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): }, key=CONF_LEVEL, ), + synchronous=True, ) async def logger_set_level_to_code(config, action_id, template_arg, args): level = LOG_LEVELS[config[CONF_LEVEL]] diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index b589e42f3b7..f9adca9c337 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -182,6 +182,7 @@ async def disp_update(disp, config: dict): ), LVGL_SCHEMA, ), + synchronous=True, ) async def obj_invalidate_to_code(config, action_id, template_arg, args): if CONF_LVGL_ID in config: @@ -202,6 +203,7 @@ async def obj_invalidate_to_code(config, action_id, template_arg, args): DISP_BG_SCHEMA.extend(LVGL_SCHEMA).add_extra( cv.has_at_least_one_key(CONF_DISP_BG_COLOR, CONF_DISP_BG_IMAGE) ), + synchronous=True, ) async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) @@ -222,6 +224,7 @@ async def lvgl_update_to_code(config, action_id, template_arg, args): cv.Optional(CONF_SHOW_SNOW, default=False): lv_bool, } ), + synchronous=True, ) async def pause_action_to_code(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) @@ -237,6 +240,7 @@ async def pause_action_to_code(config, action_id, template_arg, args): "lvgl.resume", LvglAction, LVGL_SCHEMA, + synchronous=True, ) async def resume_action_to_code(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) @@ -247,7 +251,9 @@ async def resume_action_to_code(config, action_id, template_arg, args): return var -@automation.register_action("lvgl.widget.disable", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.disable", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_disable_to_code(config, action_id, template_arg, args): async def do_disable(widget: Widget): widget.add_state(LV_STATE.DISABLED) @@ -257,7 +263,9 @@ async def obj_disable_to_code(config, action_id, template_arg, args): ) -@automation.register_action("lvgl.widget.enable", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.enable", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_enable_to_code(config, action_id, template_arg, args): async def do_enable(widget: Widget): widget.clear_state(LV_STATE.DISABLED) @@ -267,7 +275,9 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) -@automation.register_action("lvgl.widget.hide", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.hide", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): widget.add_flag("LV_OBJ_FLAG_HIDDEN") @@ -276,7 +286,9 @@ async def obj_hide_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_hide, action_id, template_arg, args) -@automation.register_action("lvgl.widget.show", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.show", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): widget.clear_flag("LV_OBJ_FLAG_HIDDEN") @@ -318,6 +330,7 @@ def focused_id(value): key=CONF_ID, ), ), + synchronous=True, ) async def widget_focus(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -357,7 +370,10 @@ async def widget_focus(config, action_id, template_arg, args): @automation.register_action( - "lvgl.widget.update", ObjUpdateAction, base_update_schema(lv_obj_base_t, PARTS) + "lvgl.widget.update", + ObjUpdateAction, + base_update_schema(lv_obj_base_t, PARTS), + synchronous=True, ) async def obj_update_to_code(config, action_id, template_arg, args): async def do_update(widget: Widget): @@ -389,6 +405,7 @@ def validate_refresh_config(config): ), validate_refresh_config, ), + synchronous=True, ) async def obj_refresh_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 3969c9f3887..b9801b41332 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -59,6 +59,7 @@ async def styles_to_code(config): cv.Required(CONF_ID): cv.use_id(lv_style_t), } ), + synchronous=True, ) async def style_update_to_code(config, action_id, template_arg, args): await wait_for_widgets() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 9c92ca7e986..09d40bb7efe 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -164,6 +164,7 @@ class WidgetType: f"lvgl.{self.name}.update", ObjUpdateAction, base_update_schema(self, self.parts).extend(self.modify_schema), + synchronous=True, )(update_to_code) @property diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index b824d28fb84..8e2db5ff350 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -83,6 +83,7 @@ animimg_spec = AnimimgType() }, key=CONF_ID, ), + synchronous=True, ) async def animimg_start(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -102,6 +103,7 @@ async def animimg_start(config, action_id, template_arg, args): }, key=CONF_ID, ), + synchronous=True, ) async def animimg_stop(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index fe421aa4770..f94f12b69b2 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -245,6 +245,7 @@ buttonmatrix_spec = ButtonMatrixType() cv.Optional(CONF_SELECTED): lv_bool, } ), + synchronous=True, ) async def button_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config[CONF_ID]) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index ead352aa77d..50cc8b0af62 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -97,6 +97,7 @@ canvas_spec = CanvasType() cv.Optional(CONF_OPA, default="COVER"): opacity, }, ), + synchronous=True, ) async def canvas_fill(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -120,6 +121,7 @@ async def canvas_fill(config, action_id, template_arg, args): cv.Required(CONF_POINTS): cv.ensure_list(point_schema), }, ), + synchronous=True, ) async def canvas_set_pixel(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -229,6 +231,7 @@ RECT_PROPS = { **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, } ), + synchronous=True, ) async def canvas_draw_rect(config, action_id, template_arg, args): width = await pixels.process(config[CONF_WIDTH]) @@ -268,6 +271,7 @@ TEXT_PROPS = { **{cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS}, }, ), + synchronous=True, ) async def canvas_draw_text(config, action_id, template_arg, args): text = await lv_text.process(config[CONF_TEXT]) @@ -302,6 +306,7 @@ IMG_PROPS = { **{cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}, } ), + synchronous=True, ) async def canvas_draw_image(config, action_id, template_arg, args): src = await lv_image.process(config[CONF_SRC]) @@ -341,6 +346,7 @@ LINE_PROPS = { **{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}, } ), + synchronous=True, ) async def canvas_draw_line(config, action_id, template_arg, args): points = [ @@ -369,6 +375,7 @@ async def canvas_draw_line(config, action_id, template_arg, args): **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, }, ), + synchronous=True, ) async def canvas_draw_polygon(config, action_id, template_arg, args): points = [ @@ -408,6 +415,7 @@ ARC_PROPS = { **{cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}, } ), + synchronous=True, ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index aefda0e71a3..b7e3af9a788 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -297,6 +297,7 @@ meter_spec = MeterType() cv.Optional(CONF_OPA): opacity, } ), + synchronous=True, ) async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/page.py b/esphome/components/lvgl/widgets/page.py index 23c162e010e..7e75ab6a2da 100644 --- a/esphome/components/lvgl/widgets/page.py +++ b/esphome/components/lvgl/widgets/page.py @@ -85,6 +85,7 @@ page_spec = PageType() "lvgl.page.next", LvglAction, SHOW_SCHEMA, + synchronous=True, ) async def page_next_to_code(config, action_id, template_arg, args): animation = await LV_ANIM.process(config[CONF_ANIMATION]) @@ -125,6 +126,7 @@ async def page_is_showing_to_code(config, condition_id, template_arg, args): "lvgl.page.previous", LvglAction, SHOW_SCHEMA, + synchronous=True, ) async def page_previous_to_code(config, action_id, template_arg, args): animation = await LV_ANIM.process(config[CONF_ANIMATION]) @@ -148,6 +150,7 @@ async def page_previous_to_code(config, action_id, template_arg, args): ), key=CONF_ID, ), + synchronous=True, ) async def page_show_to_code(config, action_id, template_arg, args): widget = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index c6f25e9587c..58e3435c5c7 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -147,6 +147,7 @@ spinbox_spec = SpinboxType() }, key=CONF_ID, ), + synchronous=True, ) async def spinbox_increment(config, action_id, template_arg, args): widgets = await get_widgets(config) @@ -166,6 +167,7 @@ async def spinbox_increment(config, action_id, template_arg, args): }, key=CONF_ID, ), + synchronous=True, ) async def spinbox_decrement(config, action_id, template_arg, args): widgets = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index e8931bab7ca..cd7cf7b4716 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -109,6 +109,7 @@ tabview_spec = TabviewType() cv.Required(CONF_INDEX): lv_int, }, ).add_extra(cv.has_at_least_one_key(CONF_INDEX, CONF_TAB_ID)), + synchronous=True, ) async def tabview_select(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 5e3a95f017b..430a386d2e2 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -112,6 +112,7 @@ def tile_select_validate(config): cv.Optional(CONF_TILE_ID): cv.use_id(lv_tile_t), }, ).add_extra(tile_select_validate), + synchronous=True, ) async def tileview_select(config, action_id, template_arg, args): widgets = await get_widgets(config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 3da0f953b09..ebb045dfcea 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -71,7 +71,9 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA) +@automation.register_action( + "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True +) async def max17043_sleep_mode_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 0d2ff527c7d..be6390fc17f 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -112,6 +112,7 @@ async def max6956_pin_to_code(config): }, key=CONF_BRIGHTNESS_GLOBAL, ), + synchronous=True, ) async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -133,6 +134,7 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, }, key=CONF_BRIGHTNESS_MODE, ), + synchronous=True, ) async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index a251eaccea5..eb751b995d1 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -133,10 +133,16 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "max7219digit.invert_off", DisplayInvertAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.invert_off", + DisplayInvertAction, + MAX7219_OFF_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "max7219digit.invert_on", DisplayInvertAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.invert_on", + DisplayInvertAction, + MAX7219_ON_ACTION_SCHEMA, + synchronous=True, ) async def max7219digit_invert_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -146,10 +152,16 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): @automation.register_action( - "max7219digit.turn_off", DisplayVisibilityAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.turn_off", + DisplayVisibilityAction, + MAX7219_OFF_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "max7219digit.turn_on", DisplayVisibilityAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.turn_on", + DisplayVisibilityAction, + MAX7219_ON_ACTION_SCHEMA, + synchronous=True, ) async def max7219digit_visible_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -159,10 +171,16 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): @automation.register_action( - "max7219digit.reverse_off", DisplayReverseAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.reverse_off", + DisplayReverseAction, + MAX7219_OFF_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "max7219digit.reverse_on", DisplayReverseAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.reverse_on", + DisplayReverseAction, + MAX7219_ON_ACTION_SCHEMA, + synchronous=True, ) async def max7219digit_reverse_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -183,7 +201,10 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "max7219digit.intensity", DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA + "max7219digit.intensity", + DisplayIntensityAction, + MAX7219_INTENSITY_SCHEMA, + synchronous=True, ) async def max7219digit_intensity_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 051e386eaf7..a5baca29940 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -177,6 +177,7 @@ MEDIA_PLAYER_CONDITION_SCHEMA = automation.maybe_simple_id( }, key=CONF_MEDIA_URL, ), + synchronous=True, ) async def media_player_play_media_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -206,7 +207,10 @@ def _register_command_actions(): class_name, automation.Action, cg.Parented.template(MediaPlayer) ) automation.register_action( - f"media_player.{action_name}", action_class, MEDIA_PLAYER_ACTION_SCHEMA + f"media_player.{action_name}", + action_class, + MEDIA_PLAYER_ACTION_SCHEMA, + synchronous=True, )(handler) @@ -242,6 +246,7 @@ _register_state_conditions() }, key=CONF_VOLUME, ), + synchronous=True, ) async def media_player_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 2841afde7ac..b7d0ad1998e 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -112,13 +112,22 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "mhz19.calibrate_zero", MHZ19CalibrateZeroAction, NO_ARGS_ACTION_SCHEMA + "mhz19.calibrate_zero", + MHZ19CalibrateZeroAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "mhz19.abc_enable", MHZ19ABCEnableAction, NO_ARGS_ACTION_SCHEMA + "mhz19.abc_enable", + MHZ19ABCEnableAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "mhz19.abc_disable", MHZ19ABCDisableAction, NO_ARGS_ACTION_SCHEMA + "mhz19.abc_disable", + MHZ19ABCDisableAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def mhz19_no_args_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -137,7 +146,10 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "mhz19.detection_range_set", MHZ19DetectionRangeSetAction, RANGE_ACTION_SCHEMA + "mhz19.detection_range_set", + MHZ19DetectionRangeSetAction, + RANGE_ACTION_SCHEMA, + synchronous=True, ) async def mhz19_detection_range_set_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 74696584da9..372eb4c3b00 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -529,8 +529,15 @@ async def to_code(config): MICRO_WAKE_WORD_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(MicroWakeWord)}) -@register_action("micro_wake_word.start", StartAction, MICRO_WAKE_WORD_ACTION_SCHEMA) -@register_action("micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA) +@register_action( + "micro_wake_word.start", + StartAction, + MICRO_WAKE_WORD_ACTION_SCHEMA, + synchronous=True, +) +@register_action( + "micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA, synchronous=True +) @register_condition( "micro_wake_word.is_running", IsRunningCondition, MICRO_WAKE_WORD_ACTION_SCHEMA ) @@ -551,11 +558,13 @@ MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( "micro_wake_word.enable_model", EnableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + synchronous=True, ) @register_action( "micro_wake_word.disable_model", DisableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + synchronous=True, ) @register_condition( "micro_wake_word.model_is_enabled", diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index ce314844134..6b5ee8c3e19 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -190,19 +190,25 @@ async def microphone_action(config, action_id, template_arg, args): automation.register_action( - "microphone.capture", CaptureAction, MICROPHONE_ACTION_SCHEMA + "microphone.capture", + CaptureAction, + MICROPHONE_ACTION_SCHEMA, + synchronous=True, )(microphone_action) automation.register_action( - "microphone.stop_capture", StopCaptureAction, MICROPHONE_ACTION_SCHEMA + "microphone.stop_capture", + StopCaptureAction, + MICROPHONE_ACTION_SCHEMA, + synchronous=True, )(microphone_action) -automation.register_action("microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA)( - microphone_action -) -automation.register_action("microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA)( - microphone_action -) +automation.register_action( + "microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True +)(microphone_action) +automation.register_action( + "microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True +)(microphone_action) automation.register_condition( "microphone.is_capturing", IsCapturingCondition, MICROPHONE_ACTION_SCHEMA diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 8a3d4f22ba5..c954b450330 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -53,7 +53,9 @@ def templatize(value): def register_action(name, type_, schema): validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action(f"midea_ac.{name}", type_, validator) + registerer = automation.register_action( + f"midea_ac.{name}", type_, validator, synchronous=True + ) def decorator(func): async def new_func(config, action_id, template_arg, args): diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3025d71210..63b419cc98e 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -162,6 +162,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def ducking_set_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index c25c4720387..d110d7c1602 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -607,6 +607,7 @@ async def mqtt_connected_to_code(config, condition_id, template_arg, args): cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + synchronous=True, ) async def mqtt_enable_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -621,6 +622,7 @@ async def mqtt_enable_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + synchronous=True, ) async def mqtt_disable_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 9192f48f53c..9798c1c2970 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -117,16 +117,19 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( "nau7802.calibrate_internal_offset", NAU7802CalbrateInternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, + synchronous=True, ) @automation.register_action( "nau7802.calibrate_external_offset", NAU7802CalbrateExternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, + synchronous=True, ) @automation.register_action( "nau7802.calibrate_gain", NAU7802CalbrateGainAction, NAU7802_CALIBRATE_SCHEMA, + synchronous=True, ) async def nau7802_calibrate_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 7ef72c6491b..5b5922887ce 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -70,6 +70,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index b8fcd5d8cfa..5b2dfc488d2 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -172,6 +172,7 @@ CONFIG_SCHEMA = cv.All( }, key=CONF_BRIGHTNESS, ), + synchronous=True, ) async def nextion_set_brightness_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 9802762ff35..cab531f1db6 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -110,6 +110,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 1974ff3b9ea..81e6721d0f9 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -52,6 +52,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/text_sensor/__init__.py b/esphome/components/nextion/text_sensor/__init__.py index 8fc0a8ceaf0..168a6724979 100644 --- a/esphome/components/nextion/text_sensor/__init__.py +++ b/esphome/components/nextion/text_sensor/__init__.py @@ -48,6 +48,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 057244e03d4..35a9de3537e 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -106,9 +106,14 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action("online_image.set_url", SetUrlAction, SET_URL_SCHEMA) @automation.register_action( - "online_image.release", ReleaseImageAction, RELEASE_IMAGE_SCHEMA + "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +) +@automation.register_action( + "online_image.release", + ReleaseImageAction, + RELEASE_IMAGE_SCHEMA, + synchronous=True, ) async def online_image_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index a4c960927ba..a4ce2b2d1a2 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -118,6 +118,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): cv.Required(CONF_MIN_POWER): cv.templatable(cv.percentage), } ), + synchronous=True, ) async def output_set_min_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -136,6 +137,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): cv.Required(CONF_MAX_POWER): cv.templatable(cv.percentage), } ), + synchronous=True, ) async def output_set_max_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index f3c0c3230f6..8e19178cc99 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -29,6 +29,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(PCF85063Component), } ), + synchronous=True, ) async def pcf85063_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -44,6 +45,7 @@ async def pcf85063_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(PCF85063Component), } ), + synchronous=True, ) async def pcf85063_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index e3b3b572aa3..0d4de3cb73d 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -32,6 +32,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(pcf8563Component), } ), + synchronous=True, ) async def pcf8563_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -47,6 +48,7 @@ async def pcf8563_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(pcf8563Component), } ), + synchronous=True, ) async def pcf8563_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 5fa3166f9df..0e66b676377 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -133,6 +133,7 @@ async def to_code(config): cv.Required(CONF_ID): cv.use_id(PIDClimate), } ), + synchronous=True, ) async def pid_reset_integral_term(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -154,6 +155,7 @@ async def pid_reset_integral_term(config, action_id, template_arg, args): ): cv.possibly_negative_percentage, } ), + synchronous=True, ) async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -175,6 +177,7 @@ async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): cv.Optional(CONF_KD, default=0.0): cv.templatable(cv.float_), } ), + synchronous=True, ) async def set_control_parameters(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 829f8f70370..81e99e15a29 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -98,6 +98,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), } ), + synchronous=True, ) async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 075b9b00b52..bb40f3e4995 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -106,11 +106,13 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( "pmwcs3.air_calibration", PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, + synchronous=True, ) @automation.register_action( "pmwcs3.water_calibration", PMWCS3WaterCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, + synchronous=True, ) async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -130,6 +132,7 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( "pmwcs3.new_i2c_address", PMWCS3NewI2cAddressAction, PMWCS3_NEW_I2C_ADDRESS_SCHEMA, + synchronous=True, ) async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 131b56e30ea..6af14128818 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -119,11 +119,13 @@ PN7150_SCHEMA = cv.Schema( "tag.set_emulation_message", SetEmulationMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "tag.set_write_message", SetWriteMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) async def pn7150_set_message_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -138,22 +140,43 @@ async def pn7150_set_message_to_code(config, action_id, template_arg, args): @automation.register_action( - "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA -) -@automation.register_action("tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action( - "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_off", + EmulationOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "tag.set_clean_mode", + SetCleanModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_format_mode", + SetFormatModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_read_mode", + SetReadModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_write_mode", + SetWriteModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) async def pn7150_simple_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 899ecd595ea..54e4b74796b 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -123,11 +123,13 @@ PN7160_SCHEMA = cv.Schema( "tag.set_emulation_message", SetEmulationMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "tag.set_write_message", SetWriteMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) async def pn7160_set_message_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -142,22 +144,43 @@ async def pn7160_set_message_to_code(config, action_id, template_arg, args): @automation.register_action( - "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA -) -@automation.register_action("tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action( - "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_off", + EmulationOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "tag.set_clean_mode", + SetCleanModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_format_mode", + SetFormatModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_read_mode", + SetReadModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_write_mode", + SetWriteModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) async def pn7160_simple_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 01244635679..c09d778eda2 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -155,6 +155,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), + synchronous=True, ) async def set_total_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index ca026eefa42..499b7309c88 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -105,6 +105,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), + synchronous=True, ) async def set_total_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 3af73b86951..fa1c3961d0f 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -88,6 +88,7 @@ CONFIG_SCHEMA = ( cv.Required(CONF_ID): cv.use_id(PZEMAC), } ), + synchronous=True, ) async def reset_energy_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 383a9dbb2c5..3291be4c341 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -72,6 +72,7 @@ CONFIG_SCHEMA = ( cv.GenerateID(CONF_ID): cv.use_id(PZEMDC), } ), + synchronous=True, ) async def reset_energy_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 9d3e655c571..bf17ac27b8f 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -163,7 +163,10 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( def register_action(name, type_, schema): validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", type_, validator + f"remote_transmitter.transmit_{name}", + type_, + validator, + synchronous=True, ) def decorator(func): diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 371dbb685f7..89019e296e5 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -120,7 +120,10 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "remote_transmitter.digital_write", DigitalWriteAction, DIGITAL_WRITE_ACTION_SCHEMA + "remote_transmitter.digital_write", + DigitalWriteAction, + DIGITAL_WRITE_ACTION_SCHEMA, + synchronous=True, ) async def digital_write_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index b4770726b4f..934f24b7890 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -114,7 +114,10 @@ RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( @automation.register_action( - "rf_bridge.send_code", RFBridgeSendCodeAction, RFBRIDGE_SEND_CODE_SCHEMA + "rf_bridge.send_code", + RFBridgeSendCodeAction, + RFBRIDGE_SEND_CODE_SCHEMA, + synchronous=True, ) async def rf_bridge_send_code_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) @@ -133,7 +136,9 @@ async def rf_bridge_send_code_to_code(config, action_id, template_args, args): RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) -@automation.register_action("rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA) +@automation.register_action( + "rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA, synchronous=True +) async def rf_bridge_learnx_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_args, paren) @@ -143,6 +148,7 @@ async def rf_bridge_learnx_to_code(config, action_id, template_args, args): "rf_bridge.start_advanced_sniffing", RFBridgeStartAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, + synchronous=True, ) async def rf_bridge_start_advanced_sniffing_to_code( config, action_id, template_args, args @@ -155,6 +161,7 @@ async def rf_bridge_start_advanced_sniffing_to_code( "rf_bridge.stop_advanced_sniffing", RFBridgeStopAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, + synchronous=True, ) async def rf_bridge_stop_advanced_sniffing_to_code( config, action_id, template_args, args @@ -167,6 +174,7 @@ async def rf_bridge_stop_advanced_sniffing_to_code( "rf_bridge.start_bucket_sniffing", RFBridgeStartBucketSniffingAction, RFBRIDGE_ID_SCHEMA, + synchronous=True, ) async def rf_bridge_start_bucket_sniffing_to_code( config, action_id, template_args, args @@ -189,6 +197,7 @@ RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( "rf_bridge.send_advanced_code", RFBridgeSendAdvancedCodeAction, RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA, + synchronous=True, ) async def rf_bridge_send_advanced_code_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) @@ -211,7 +220,10 @@ RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( @automation.register_action( - "rf_bridge.send_raw", RFBridgeSendRawAction, RFBRIDGE_SEND_RAW_SCHEMA + "rf_bridge.send_raw", + RFBridgeSendRawAction, + RFBRIDGE_SEND_RAW_SCHEMA, + synchronous=True, ) async def rf_bridge_send_raw_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) @@ -229,7 +241,9 @@ RFBRIDGE_BEEP_SCHEMA = cv.Schema( ) -@automation.register_action("rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA) +@automation.register_action( + "rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA, synchronous=True +) async def rf_bridge_beep_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_args, paren) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 645b4a81c5c..be315db55d2 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -139,6 +139,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), + synchronous=True, ) async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 441a52de7f9..4ea488a6cd1 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -42,6 +42,7 @@ async def to_code(config): cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), + synchronous=True, ) async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 19412bb4547..35667342006 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -117,6 +117,7 @@ async def to_code(config): }, key=CONF_RTTTL, ), + synchronous=True, ) async def rtttl_play_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -134,6 +135,7 @@ async def rtttl_play_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(Rtttl), } ), + synchronous=True, ) async def rtttl_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index cb0402bd323..4f6310358c5 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -27,6 +27,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(RX8130Component), } ), + synchronous=True, ) async def rx8130_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -42,6 +43,7 @@ async def rx8130_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(RX8130Component), } ), + synchronous=True, ) async def rx8130_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index f54151b7460..e868985054e 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -62,6 +62,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.use_id(SafeModeComponent), } ), + synchronous=True, ) async def safe_mode_mark_successful_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 194df8ec4f5..f60e913a0cb 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -128,6 +128,7 @@ async def to_code(config): }, key=CONF_VALUE, ), + synchronous=True, ) async def scd30_force_recalibration_with_reference_to_code( config, action_id, template_arg, args diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index ec90234ac39..6f14118660a 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -141,6 +141,7 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( "scd4x.perform_forced_calibration", PerformForcedCalibrationAction, SCD4X_ACTION_SCHEMA, + synchronous=True, ) async def scd4x_frc_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -158,7 +159,10 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "scd4x.factory_reset", FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA + "scd4x.factory_reset", + FactoryResetAction, + SCD4X_RESET_ACTION_SCHEMA, + synchronous=True, ) async def scd4x_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 369cefad91a..51cae695b76 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -221,6 +221,7 @@ async def script_stop_action_to_code(config, action_id, template_arg, args): "script.wait", ScriptWaitAction, maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + synchronous=False, ) async def script_wait_action_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 538a2f52395..9fe51121f16 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -267,7 +267,10 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "sen5x.start_fan_autoclean", StartFanAction, SEN5X_ACTION_SCHEMA + "sen5x.start_fan_autoclean", + StartFanAction, + SEN5X_ACTION_SCHEMA, + synchronous=True, ) async def sen54_fan_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 2eb2617e307..c5bef76741a 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -73,20 +73,31 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( "senseair.background_calibration", SenseAirBackgroundCalibrationAction, CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "senseair.background_calibration_result", SenseAirBackgroundCalibrationResultAction, CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "senseair.abc_enable", SenseAirABCEnableAction, CALIBRATION_ACTION_SCHEMA + "senseair.abc_enable", + SenseAirABCEnableAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "senseair.abc_disable", SenseAirABCDisableAction, CALIBRATION_ACTION_SCHEMA + "senseair.abc_disable", + SenseAirABCDisableAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "senseair.abc_get_period", SenseAirABCGetPeriodAction, CALIBRATION_ACTION_SCHEMA + "senseair.abc_get_period", + SenseAirABCGetPeriodAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) async def senseair_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 2fee2840a5c..a23bb53536a 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -62,6 +62,7 @@ async def to_code(config): cv.Required(CONF_LEVEL): cv.templatable(cv.possibly_negative_percentage), } ), + synchronous=True, ) async def servo_write_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -79,6 +80,7 @@ async def servo_write_to_code(config, action_id, template_arg, args): cv.Required(CONF_ID): cv.use_id(Servo), } ), + synchronous=True, ) async def servo_detach_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index c48a3c63c41..ebb74302a9c 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -135,7 +135,10 @@ SIM800L_SEND_SMS_SCHEMA = cv.Schema( @automation.register_action( - "sim800l.send_sms", Sim800LSendSmsAction, SIM800L_SEND_SMS_SCHEMA + "sim800l.send_sms", + Sim800LSendSmsAction, + SIM800L_SEND_SMS_SCHEMA, + synchronous=True, ) async def sim800l_send_sms_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -155,7 +158,9 @@ SIM800L_DIAL_SCHEMA = cv.Schema( ) -@automation.register_action("sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA) +@automation.register_action( + "sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA, synchronous=True +) async def sim800l_dial_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -168,6 +173,7 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): "sim800l.connect", Sim800LConnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), + synchronous=True, ) async def sim800l_connect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -183,7 +189,10 @@ SIM800L_SEND_USSD_SCHEMA = cv.Schema( @automation.register_action( - "sim800l.send_ussd", Sim800LSendUssdAction, SIM800L_SEND_USSD_SCHEMA + "sim800l.send_ussd", + Sim800LSendUssdAction, + SIM800L_SEND_USSD_SCHEMA, + synchronous=True, ) async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -197,6 +206,7 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): "sim800l.disconnect", Sim800LDisconnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), + synchronous=True, ) async def sim800l_disconnect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index 8ca0feccc0f..44f31979b46 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -89,8 +89,12 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action("sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA) -@automation.register_action("sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA) +@automation.register_action( + "sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +) async def sound_level_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 10ee6d52125..8480eebcdb2 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -78,6 +78,7 @@ async def speaker_action(config, action_id, template_arg, args): }, key=CONF_DATA, ), + synchronous=True, ) async def speaker_play_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -95,12 +96,12 @@ async def speaker_play_action(config, action_id, template_arg, args): return var -automation.register_action("speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA)( - speaker_action -) -automation.register_action("speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA)( - speaker_action -) +automation.register_action( + "speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True +)(speaker_action) +automation.register_action( + "speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True +)(speaker_action) automation.register_condition( "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA @@ -121,6 +122,7 @@ automation.register_condition( }, key=CONF_VOLUME, ), + synchronous=True, ) async def speaker_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -131,9 +133,14 @@ async def speaker_volume_set_action(config, action_id, template_arg, args): @automation.register_action( - "speaker.mute_off", MuteOffAction, SPEAKER_AUTOMATION_SCHEMA + "speaker.mute_off", + MuteOffAction, + SPEAKER_AUTOMATION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True ) -@automation.register_action("speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA) async def speaker_mute_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 42ca762858f..92a178fe95e 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -505,6 +505,7 @@ async def to_code(config): }, key=CONF_MEDIA_FILE, ), + synchronous=True, ) async def play_on_device_media_media_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 50c69f94967..6e2ff4ee2e1 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -422,6 +422,7 @@ CONFIG_SCHEMA = cv.All( "sprinkler.set_divider", SetDividerAction, SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, + synchronous=True, ) async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -435,6 +436,7 @@ async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): "sprinkler.set_multiplier", SetMultiplierAction, SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, + synchronous=True, ) async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -448,6 +450,7 @@ async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args "sprinkler.queue_valve", QueueValveAction, SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, + synchronous=True, ) async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -463,6 +466,7 @@ async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, ar "sprinkler.set_repeat", SetRepeatAction, SPRINKLER_ACTION_REPEAT_SCHEMA, + synchronous=True, ) async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -476,6 +480,7 @@ async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): "sprinkler.set_valve_run_duration", SetRunDurationAction, SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, + synchronous=True, ) async def sprinkler_set_valve_run_duration_to_code( config, action_id, template_arg, args @@ -490,7 +495,10 @@ async def sprinkler_set_valve_run_duration_to_code( @automation.register_action( - "sprinkler.start_from_queue", StartFromQueueAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.start_from_queue", + StartFromQueueAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -498,7 +506,10 @@ async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, ar @automation.register_action( - "sprinkler.start_full_cycle", StartFullCycleAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.start_full_cycle", + StartFullCycleAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -509,6 +520,7 @@ async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, ar "sprinkler.start_single_valve", StartSingleValveAction, SPRINKLER_ACTION_SINGLE_VALVE_SCHEMA, + synchronous=True, ) async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -522,21 +534,40 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, @automation.register_action( - "sprinkler.clear_queued_valves", ClearQueuedValvesAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.clear_queued_valves", + ClearQueuedValvesAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sprinkler.next_valve", NextValveAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.next_valve", + NextValveAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sprinkler.previous_valve", PreviousValveAction, SPRINKLER_ACTION_SCHEMA -) -@automation.register_action("sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA) -@automation.register_action("sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA) -@automation.register_action( - "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.previous_valve", + PreviousValveAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sprinkler.shutdown", ShutdownAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "sprinkler.resume_or_start_full_cycle", + ResumeOrStartAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "sprinkler.shutdown", + ShutdownAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) async def sprinkler_simple_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 3c967fc01be..40557f2cbde 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -180,13 +180,22 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "sps30.start_fan_autoclean", StartFanAction, SPS30_ACTION_SCHEMA + "sps30.start_fan_autoclean", + StartFanAction, + SPS30_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sps30.start_measurement", StartMeasurementAction, SPS30_ACTION_SCHEMA + "sps30.start_measurement", + StartMeasurementAction, + SPS30_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sps30.stop_measurement", StopMeasurementAction, SPS30_ACTION_SCHEMA + "sps30.stop_measurement", + StopMeasurementAction, + SPS30_ACTION_SCHEMA, + synchronous=True, ) async def sps30_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 62bc71f2d17..27d4fc276d9 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -97,6 +97,7 @@ async def register_stepper(var, config): cv.Required(CONF_TARGET): cv.templatable(cv.int_), } ), + synchronous=True, ) async def stepper_set_target_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -115,6 +116,7 @@ async def stepper_set_target_to_code(config, action_id, template_arg, args): cv.Required(CONF_POSITION): cv.templatable(cv.int_), } ), + synchronous=True, ) async def stepper_report_position_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -133,6 +135,7 @@ async def stepper_report_position_to_code(config, action_id, template_arg, args) cv.Required(CONF_SPEED): cv.templatable(validate_speed), } ), + synchronous=True, ) async def stepper_set_speed_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -151,6 +154,7 @@ async def stepper_set_speed_to_code(config, action_id, template_arg, args): cv.Required(CONF_ACCELERATION): cv.templatable(validate_acceleration), } ), + synchronous=True, ) async def stepper_set_acceleration_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -169,6 +173,7 @@ async def stepper_set_acceleration_to_code(config, action_id, template_arg, args cv.Required(CONF_DECELERATION): cv.templatable(validate_acceleration), } ), + synchronous=True, ) async def stepper_set_deceleration_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 413eb139d65..08f4c0fb882 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -290,19 +290,34 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "sx126x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA + "sx126x.run_image_cal", + RunImageCalAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_tx", + SetModeTxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_rx", + SetModeRxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_sleep", SetModeSleepAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_sleep", + SetModeSleepAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_standby", + SetModeStandbyAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def no_args_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -320,7 +335,10 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "sx126x.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA + "sx126x.send_packet", + SendPacketAction, + SEND_PACKET_ACTION_SCHEMA, + synchronous=True, ) async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index f3a9cca93f4..7f554fbf841 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -283,19 +283,34 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "sx127x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA + "sx127x.run_image_cal", + RunImageCalAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_tx", + SetModeTxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_rx", + SetModeRxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_sleep", SetModeSleepAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_sleep", + SetModeSleepAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_standby", + SetModeStandbyAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def no_args_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -313,7 +328,10 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "sx127x.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA + "sx127x.send_packet", + SendPacketAction, + SEND_PACKET_ACTION_SCHEMA, + synchronous=True, ) async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 9d4208dcca5..e537e1f97cf 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -59,6 +59,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index a4fb0b70210..cfc19c00cdf 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -125,6 +125,7 @@ async def to_code(config): cv.Optional(CONF_TILT): cv.templatable(cv.zero_to_one_float), } ), + synchronous=True, ) async def cover_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/lock/__init__.py b/esphome/components/template/lock/__init__.py index 4c74a521fa2..d8bd9d16c66 100644 --- a/esphome/components/template/lock/__init__.py +++ b/esphome/components/template/lock/__init__.py @@ -90,6 +90,7 @@ async def to_code(config): }, key=CONF_STATE, ), + synchronous=True, ) async def lock_template_publish_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 2c325427e99..b0f48ade465 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -44,6 +44,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.float_), } ), + synchronous=True, ) async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 8ae5a07dc3b..eb6f0f46de7 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -80,6 +80,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def switch_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index 550b27356d1..ddbdd6dadb7 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -43,6 +43,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), + synchronous=True, ) async def text_sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index 526751564de..3e8fd816030 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -112,6 +112,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def valve_template_publish_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index cb5f2dbe56d..814aa401935 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -134,6 +134,7 @@ async def to_code(config: ConfigType) -> None: cv.Optional(CONF_IS_ON): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def water_heater_template_publish_to_code( config: ConfigType, diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 49796d9b426..fb35eb21b51 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -73,6 +73,7 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( }, key=CONF_BRIGHTNESS, ), + synchronous=True, ) async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -92,6 +93,7 @@ async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): }, key=CONF_LEVEL, ), + synchronous=True, ) async def tm1651_set_level_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -111,6 +113,7 @@ async def tm1651_set_level_to_code(config, action_id, template_arg, args): }, key=CONF_LEVEL_PERCENT, ), + synchronous=True, ) async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -121,7 +124,10 @@ async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args @automation.register_action( - "tm1651.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA + "tm1651.turn_off", + TurnOffAction, + BINARY_OUTPUT_ACTION_SCHEMA, + synchronous=True, ) async def output_turn_off_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -129,7 +135,9 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): return var -@automation.register_action("tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA) +@automation.register_action( + "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True +) async def output_turn_on_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 2cb6eac0509..83649cc2092 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -500,6 +500,7 @@ async def register_uart_device(var, config): }, key=CONF_DATA, ), + synchronous=True, ) async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 37dd871a6c1..17bbf19c9ea 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -171,6 +171,7 @@ def validate_raw_data(value): }, key=CONF_DATA, ), + synchronous=True, ) async def udp_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 9edf0f89ffa..10b4ece6141 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -97,6 +97,7 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( "ufire_ec.calibrate_probe", UFireECCalibrateProbeAction, UFIRE_EC_CALIBRATE_PROBE_SCHEMA, + synchronous=True, ) async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -119,6 +120,7 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( "ufire_ec.reset", UFireECResetAction, UFIRE_EC_RESET_SCHEMA, + synchronous=True, ) async def ufire_ec_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 8009cdaa6a8..a116012d055 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -91,6 +91,7 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( "ufire_ise.calibrate_probe_low", UFireISECalibrateProbeLowAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, + synchronous=True, ) async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -104,6 +105,7 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, "ufire_ise.calibrate_probe_high", UFireISECalibrateProbeHighAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, + synchronous=True, ) async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -120,6 +122,7 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent "ufire_ise.reset", UFireISEResetAction, UFIRE_ISE_RESET_SCHEMA, + synchronous=True, ) async def ufire_ise_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index c36a4ab769d..db6c1445e34 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -138,6 +138,7 @@ async def to_code(config): cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def update_perform_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -156,6 +157,7 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(UpdateEntity), } ), + synchronous=True, ) async def update_check_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 22cd01988d8..0319ff50e7f 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -180,25 +180,33 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("valve.open", OpenAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.open", OpenAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_open_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("valve.close", CloseAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.close", CloseAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_close_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("valve.stop", StopAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.stop", StopAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_stop_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_toggle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -214,7 +222,9 @@ VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action("valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA) +@automation.register_action( + "valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA, synchronous=True +) async def valve_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 8b7dcb4f212..d970df2a44b 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -393,6 +393,7 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis "voice_assistant.start_continuous", StartContinuousAction, VOICE_ASSISTANT_ACTION_SCHEMA, + synchronous=True, ) @register_action( "voice_assistant.start", @@ -403,6 +404,7 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis cv.Optional(CONF_WAKE_WORD): cv.templatable(cv.string), } ), + synchronous=True, ) async def voice_assistant_listen_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -415,7 +417,9 @@ async def voice_assistant_listen_to_code(config, action_id, template_arg, args): return var -@register_action("voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA) +@register_action( + "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True +) async def voice_assistant_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 0f86ec059ee..2808d313111 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -670,12 +670,16 @@ async def wifi_ap_active_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg) -@automation.register_action("wifi.enable", WiFiEnableAction, cv.Schema({})) +@automation.register_action( + "wifi.enable", WiFiEnableAction, cv.Schema({}), synchronous=True +) async def wifi_enable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) -@automation.register_action("wifi.disable", WiFiDisableAction, cv.Schema({})) +@automation.register_action( + "wifi.disable", WiFiDisableAction, cv.Schema({}), synchronous=True +) async def wifi_disable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) @@ -781,6 +785,7 @@ async def final_step(): cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), } ), + synchronous=False, ) async def wifi_set_sta_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 124d9a8c328..e2ea61a5428 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -168,6 +168,7 @@ async def wireguard_enabled_to_code(config, condition_id, template_arg, args): "wireguard.enable", WireguardEnableAction, cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), + synchronous=True, ) async def wireguard_enable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -179,6 +180,7 @@ async def wireguard_enable_to_code(config, action_id, template_arg, args): "wireguard.disable", WireguardDisableAction, cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), + synchronous=True, ) async def wireguard_disable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index a327cc29886..280ff6b50cc 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -195,6 +195,7 @@ FactoryResetAction = zigbee_ns.class_( "zigbee.factory_reset", FactoryResetAction, ZIGBEE_ACTION_SCHEMA, + synchronous=True, ) async def reset_zigbee_to_code( config: ConfigType, From 4d2ef09a296c4fd3e1b5e723024770ab8b8c3339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:12:10 -1000 Subject: [PATCH 09/44] [log] Detect early log calls before logger init and optimize hot path (#14538) --- esphome/components/logger/__init__.py | 5 +- esphome/core/application.h | 2 + esphome/core/log.cpp | 60 +++++++++++++------ esphome/core/log.h | 12 +++- script/cpp_unit_test.py | 1 + tests/component_tests/logger/__init__.py | 0 tests/component_tests/logger/test_logger.py | 50 ++++++++++++++++ tests/component_tests/logger/test_logger.yaml | 14 +++++ tests/components/main.cpp | 8 +++ tests/integration/conftest.py | 1 + 10 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/logger/__init__.py create mode 100644 tests/component_tests/logger/test_logger.py create mode 100644 tests/component_tests/logger/test_logger.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 83a78541652..e370f4215d4 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -337,6 +337,10 @@ async def to_code(config): ) if CORE.is_esp32: cg.add(log.create_pthread_key()) + # pre_setup() must be called before init_log_buffer() because + # init_log_buffer() calls disable_loop() which may log at VV level, + # and global_logger must be set before any logging occurs. + cg.add(log.pre_setup()) if CORE.is_esp32 or CORE.is_libretiny or CORE.is_nrf52: task_log_buffer_size = config[CONF_TASK_LOG_BUFFER_SIZE] if task_log_buffer_size > 0: @@ -356,7 +360,6 @@ async def to_code(config): HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] ) ) - cg.add(log.pre_setup()) # Enable runtime tag levels if logs are configured or explicitly enabled logs_config = config[CONF_LOGS] diff --git a/esphome/core/application.h b/esphome/core/application.h index f357c6b1a3d..23bb209eaf4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -142,6 +142,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX + // Called before Logger::pre_setup() — must not log (global_logger is not yet set). /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -163,6 +164,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else + // Called before Logger::pre_setup() — must not log (global_logger is not yet set). /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 8338efbb33c..0da457adec2 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -1,6 +1,7 @@ #include "log.h" #include "defines.h" #include "helpers.h" +#include #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -8,40 +9,63 @@ namespace esphome { +#ifdef ESPHOME_DEBUG +static void early_log_printf_(const char *tag, int line, const char *format, va_list args) { + fprintf(stderr, "LOG BEFORE LOGGER INIT [%s:%d]: ", tag, line); + vfprintf(stderr, format, args); + fputc('\n', stderr); + assert(false && "log called before Logger::pre_setup()"); // NOLINT +} +#endif + void HOT esp_log_printf_(int level, const char *tag, int line, const char *format, ...) { // NOLINT +#ifdef USE_LOGGER +#ifdef ESPHOME_DEBUG + if (logger::global_logger == nullptr) { + va_list arg; + va_start(arg, format); + early_log_printf_(tag, line, format, arg); + va_end(arg); + return; + } +#endif va_list arg; va_start(arg, format); - esp_log_vprintf_(level, tag, line, format, arg); + logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, arg); va_end(arg); +#endif } + #ifdef USE_STORE_LOG_STR_IN_FLASH void HOT esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...) { +#ifdef USE_LOGGER + ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); va_list arg; va_start(arg, format); - esp_log_vprintf_(level, tag, line, format, arg); + logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, arg); va_end(arg); +#endif } #endif void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER - auto *log = logger::global_logger; - if (log == nullptr) +#ifdef ESPHOME_DEBUG + if (logger::global_logger == nullptr) { + early_log_printf_(tag, line, format, args); return; - - log->log_vprintf_(static_cast(level), tag, line, format, args); + } +#endif + logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); #endif } #ifdef USE_STORE_LOG_STR_IN_FLASH -void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, - va_list args) { // NOLINT +// Remove before 2026.9.0 +void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { #ifdef USE_LOGGER - auto *log = logger::global_logger; - if (log == nullptr) - return; - - log->log_vprintf_(static_cast(level), tag, line, format, args); + ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); + logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); #endif } #endif @@ -49,11 +73,13 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStr #ifdef USE_ESP32 int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER - auto *log = logger::global_logger; - if (log == nullptr) +#ifdef ESPHOME_DEBUG + if (logger::global_logger == nullptr) { + early_log_printf_("esp-idf", 0, format, args); return 0; - - log->log_vprintf_(ESPHOME_LOG_LEVEL, "esp-idf", 0, format, args); + } +#endif + logger::global_logger->log_vprintf_(ESPHOME_LOG_LEVEL, "esp-idf", 0, format, args); #endif return 0; } diff --git a/esphome/core/log.h b/esphome/core/log.h index a2c4b35c6e2..ff39633142e 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -4,6 +4,14 @@ #include #include + +// Debug assert that only fires when ESPHOME_DEBUG is defined (e.g. in CI/test builds). +// Zero cost in production firmware. +#ifdef ESPHOME_DEBUG +#define ESPHOME_DEBUG_ASSERT(expr) assert(expr) // NOLINT +#else +#define ESPHOME_DEBUG_ASSERT(expr) ((void) 0) +#endif // for PRIu32 and friends #include #include @@ -61,7 +69,9 @@ void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHe #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT #ifdef USE_STORE_LOG_STR_IN_FLASH -void esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); +// Remove before 2026.9.0 +__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_( + int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); #endif #if defined(USE_ESP32) int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index e11687dc16d..c9174584722 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -78,6 +78,7 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: "build_flags": [ "-Og", # optimize for debug "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing + "-DESPHOME_DEBUG", # enable debug assertions ], "debug_build_flags": [ # only for debug builds "-g3", # max debug info diff --git a/tests/component_tests/logger/__init__.py b/tests/component_tests/logger/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py new file mode 100644 index 00000000000..98aa7419642 --- /dev/null +++ b/tests/component_tests/logger/test_logger.py @@ -0,0 +1,50 @@ +"""Tests for the logger component.""" + +import re + + +def test_logger_pre_setup_before_other_components(generate_main): + """Logger::pre_setup() must be called before any other component is created. + + Log functions call global_logger->log_vprintf_() without a null check, + so global_logger must be set before anything can log. + """ + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + # Find the logger's pre_setup() call specifically + logger_pre_setup = re.search(r"logger_logger->pre_setup\(\)", main_cpp) + if logger_pre_setup is None: + # Fall back to finding any logger-related pre_setup + logger_pre_setup = re.search(r"logger\w*->pre_setup\(\)", main_cpp) + assert logger_pre_setup is not None, ( + "Logger pre_setup() not found in generated code" + ) + + # Find all "new " allocations (component creation) + new_allocations = list(re.finditer(r"\bnew [\w:]+", main_cpp)) + assert len(new_allocations) > 0, "No component allocations found" + + # Separate logger and non-logger allocations + logger_allocs = [a for a in new_allocations if "logger" in a.group().lower()] + non_logger_allocs = [ + a + for a in new_allocations + if "logger" not in a.group().lower() + # Skip placement new for App + and "(&App)" not in main_cpp[max(0, a.start() - 5) : a.start()] + ] + + assert len(logger_allocs) > 0, ( + f"Logger allocation not found in: {[a.group() for a in new_allocations]}" + ) + assert len(non_logger_allocs) > 0, ( + "No non-logger component allocations found — " + "add a component to test_logger.yaml so the ordering check is meaningful" + ) + + # All non-logger allocations must appear after logger pre_setup() + for alloc in non_logger_allocs: + assert alloc.start() > logger_pre_setup.start(), ( + f"Component allocation '{alloc.group()}' at position {alloc.start()} " + f"appears before logger pre_setup() at position {logger_pre_setup.start()}" + ) diff --git a/tests/component_tests/logger/test_logger.yaml b/tests/component_tests/logger/test_logger.yaml new file mode 100644 index 00000000000..f43e99d94fd --- /dev/null +++ b/tests/component_tests/logger/test_logger.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + level: DEBUG + +# Need at least one non-logger component so the ordering test +# can verify that logger pre_setup() comes before other allocations. +preferences: + flash_write_interval: 1min diff --git a/tests/components/main.cpp b/tests/components/main.cpp index 928f0e60593..373fde71516 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -1,5 +1,7 @@ #include +#include "esphome/components/logger/logger.h" + /* This special main.cpp replaces the default one. It will run all the Google Tests found in all compiled cpp files and then exit with the result @@ -18,6 +20,12 @@ void original_setup() { } void setup() { + // Log functions call global_logger->log_vprintf_() without a null check, + // so we must set up a Logger before any test that triggers logging. + static esphome::logger::Logger test_logger(0); + test_logger.set_log_level(ESPHOME_LOG_LEVEL); + test_logger.pre_setup(); + ::testing::InitGoogleTest(); int exit_code = RUN_ALL_TESTS(); exit(exit_code); diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b7f7fc60b3b..b652b4174cc 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -193,6 +193,7 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s " platformio_options:\n" " build_flags:\n" ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' ' - "-g" # Add debug symbols', ) From 9404eadaf8fe50fab155bae12252adad31530336 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 09:12:28 -1000 Subject: [PATCH 10/44] [rp2040_ble] Add BLE component for RP2040/RP2350 (#14603) --- CODEOWNERS | 1 + esphome/components/rp2040_ble/__init__.py | 31 +++++ esphome/components/rp2040_ble/rp2040_ble.cpp | 124 ++++++++++++++++++ esphome/components/rp2040_ble/rp2040_ble.h | 51 +++++++ esphome/core/defines.h | 1 + tests/components/rp2040_ble/common.yaml | 1 + .../test-disable-on-boot.rp2040-ard.yaml | 2 + .../rp2040_ble/test.rp2040-ard.yaml | 1 + 8 files changed, 212 insertions(+) create mode 100644 esphome/components/rp2040_ble/__init__.py create mode 100644 esphome/components/rp2040_ble/rp2040_ble.cpp create mode 100644 esphome/components/rp2040_ble/rp2040_ble.h create mode 100644 tests/components/rp2040_ble/common.yaml create mode 100644 tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml create mode 100644 tests/components/rp2040_ble/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index cb415bb625a..a95e100cbff 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -410,6 +410,7 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/rp2040/* @jesserockz +esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz esphome/components/rpi_dpi_rgb/* @clydebarrow diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py new file mode 100644 index 00000000000..648f22691c4 --- /dev/null +++ b/esphome/components/rp2040_ble/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["rp2040"] +CODEOWNERS = ["@bdraco"] + +rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") +RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(RP2040BLE), + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable Bluetooth in the arduino-pico build + # This switches linking from liblwip.a to liblwip-bt.a and defines + # ENABLE_CLASSIC, ENABLE_BLE, CYW43_ENABLE_BLUETOOTH + cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH") + + cg.add_define("USE_RP2040_BLE") diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp new file mode 100644 index 00000000000..4125da7ec01 --- /dev/null +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -0,0 +1,124 @@ +#include "rp2040_ble.h" + +#ifdef USE_RP2040_BLE + +#include "esphome/core/log.h" + +namespace esphome::rp2040_ble { + +static const char *const TAG = "rp2040_ble"; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +RP2040BLE *global_ble = nullptr; + +void RP2040BLE::setup() { + global_ble = this; + + if (this->enable_on_boot_) { + this->enable(); + } else { + this->state_ = BLEComponentState::DISABLED; + } +} + +void RP2040BLE::enable() { + if (this->state_ == BLEComponentState::ACTIVE || this->state_ == BLEComponentState::ENABLING) { + return; + } + + ESP_LOGD(TAG, "Enabling BLE..."); + this->state_ = BLEComponentState::ENABLING; + this->active_logged_ = false; + + if (!this->btstack_initialized_) { + // BTstack init functions are not idempotent — only call once + l2cap_init(); + sm_init(); + + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + hci_add_event_handler(&this->hci_event_callback_registration_); + + this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + sm_add_event_handler(&this->sm_event_callback_registration_); + + this->btstack_initialized_ = true; + } + + hci_power_control(HCI_POWER_ON); +} + +void RP2040BLE::disable() { + if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::OFF) { + return; + } + + ESP_LOGD(TAG, "Disabling BLE..."); + this->state_ = BLEComponentState::DISABLING; + + hci_power_control(HCI_POWER_OFF); + + this->state_ = BLEComponentState::DISABLED; + ESP_LOGD(TAG, "BLE disabled"); +} + +void RP2040BLE::loop() { + if (this->state_ == BLEComponentState::ACTIVE && !this->active_logged_) { + this->active_logged_ = true; + ESP_LOGI(TAG, "BLE active"); + } +} + +static const char *state_to_str(BLEComponentState state) { + switch (state) { + case BLEComponentState::OFF: + return "OFF"; + case BLEComponentState::ENABLING: + return "ENABLING"; + case BLEComponentState::ACTIVE: + return "ACTIVE"; + case BLEComponentState::DISABLING: + return "DISABLING"; + case BLEComponentState::DISABLED: + return "DISABLED"; + default: + return "UNKNOWN"; + } +} + +void RP2040BLE::dump_config() { + ESP_LOGCONFIG(TAG, + "RP2040 BLE:\n" + " Enable on boot: %s\n" + " State: %s", + YESNO(this->enable_on_boot_), state_to_str(this->state_)); +} + +float RP2040BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } + +void RP2040BLE::packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (global_ble == nullptr) { + return; + } + + if (type != HCI_EVENT_PACKET) { + return; + } + + uint8_t event_type = hci_event_packet_get_type(packet); + + switch (event_type) { + case BTSTACK_EVENT_STATE: { + uint8_t state = btstack_event_state_get_state(packet); + if (state == HCI_STATE_WORKING && global_ble->state_ == BLEComponentState::ENABLING) { + global_ble->state_ = BLEComponentState::ACTIVE; + } + break; + } + default: + break; + } +} + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h new file mode 100644 index 00000000000..24b3860cc1e --- /dev/null +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" // Must be included before conditional includes + +#ifdef USE_RP2040_BLE + +#include "esphome/core/component.h" + +#include + +namespace esphome::rp2040_ble { + +enum class BLEComponentState : uint8_t { + OFF = 0, + ENABLING, + ACTIVE, + DISABLING, + DISABLED, +}; + +class RP2040BLE : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void enable(); + void disable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + protected: + static void packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + + btstack_packet_callback_registration_t hci_event_callback_registration_{}; + btstack_packet_callback_registration_t sm_event_callback_registration_{}; + + BLEComponentState state_{BLEComponentState::OFF}; + bool enable_on_boot_{true}; + bool btstack_initialized_{false}; + bool active_logged_{false}; +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern RP2040BLE *global_ble; + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 51f474d80ef..44918fe00c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -339,6 +339,7 @@ #define USE_I2C #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP +#define USE_RP2040_BLE #define USE_SPI #endif diff --git a/tests/components/rp2040_ble/common.yaml b/tests/components/rp2040_ble/common.yaml new file mode 100644 index 00000000000..f6205a4724f --- /dev/null +++ b/tests/components/rp2040_ble/common.yaml @@ -0,0 +1 @@ +rp2040_ble: diff --git a/tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml b/tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml new file mode 100644 index 00000000000..2154536111f --- /dev/null +++ b/tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml @@ -0,0 +1,2 @@ +rp2040_ble: + enable_on_boot: false diff --git a/tests/components/rp2040_ble/test.rp2040-ard.yaml b/tests/components/rp2040_ble/test.rp2040-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/rp2040_ble/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 04d80cfb75ffc19eaa14fcadce805c4c57369919 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:17:30 -0400 Subject: [PATCH 11/44] [esp32_hosted] Bump esp_wifi_remote and esp_hosted versions (#14680) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32_hosted/__init__.py | 4 ++-- esphome/idf_component.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 720d81acc4a..6d49053d6d5 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -103,9 +103,9 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" if framework_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.3.2") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.11.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 550e7b9af79..acd7f7a4798 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: espressif/mdns: version: 1.10.0 espressif/esp_wifi_remote: - version: 1.3.2 + version: 1.4.0 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: @@ -20,7 +20,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.11.5 + version: 2.12.0 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 780e009bf46854cabb42fb937659569eadc23d83 Mon Sep 17 00:00:00 2001 From: mahumpula <90641469+mahumpula@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:23:49 +0100 Subject: [PATCH 12/44] [runtime_image] Add support for 8bit BMPs and fix existing issues (#10733) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/runtime_image/bmp_decoder.cpp | 109 ++++++++++++++---- .../components/runtime_image/bmp_decoder.h | 4 + tests/components/online_image/common.yaml | 4 + 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 7003f4da2ff..174f924b285 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -12,7 +12,10 @@ static const char *const TAG = "image_decoder.bmp"; int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t index = 0; - if (this->current_index_ == 0 && index == 0 && size > 14) { + if (this->current_index_ == 0) { + if (size <= 14) { + return 0; // Need more data for file header + } /** * BMP file format: * 0-1: Signature (BM) @@ -39,7 +42,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { this->current_index_ = 14; index = 14; } - if (this->current_index_ == 14 && index == 14 && size > this->data_offset_) { + if (this->current_index_ == 14) { + if (size <= this->data_offset_) { + return 0; // Need more data for DIB header and color table + } /** * BMP DIB header: * 14-17: DIB header size @@ -66,6 +72,28 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { this->width_bytes_ = (this->width_ + 7) / 8; this->padding_bytes_ = (4 - (this->width_bytes_ % 4)) % 4; break; + case 8: { + this->width_bytes_ = this->width_; + if (this->color_table_entries_ == 0) { + this->color_table_entries_ = 256; + } else if (this->color_table_entries_ > 256) { + ESP_LOGE(TAG, "Too many color table entries: %" PRIu32, this->color_table_entries_); + return DECODE_ERROR_UNSUPPORTED_FORMAT; + } + size_t header_size = encode_uint32(buffer[17], buffer[16], buffer[15], buffer[14]); + size_t offset = 14 + header_size; + + this->color_table_ = std::make_unique(this->color_table_entries_); + + for (size_t i = 0; i < this->color_table_entries_; i++) { + this->color_table_[i] = encode_uint32(buffer[offset + i * 4 + 3], buffer[offset + i * 4 + 2], + buffer[offset + i * 4 + 1], buffer[offset + i * 4]); + } + + this->padding_bytes_ = (4 - (this->width_bytes_ % 4)) % 4; + + break; + } case 24: this->width_bytes_ = this->width_ * 3; if (this->width_bytes_ % 4 != 0) { @@ -91,21 +119,24 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { } switch (this->bits_per_pixel_) { case 1: { + size_t width = static_cast(this->width_); while (index < size) { - uint8_t current_byte = buffer[index]; - bool end_of_row = false; - for (uint8_t i = 0; i < 8; i++) { - size_t x = this->paint_index_ % static_cast(this->width_); - size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / static_cast(this->width_)); - Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF; - this->draw(x, y, 1, 1, c); - this->paint_index_++; - // End of pixel row: skip remaining bits in this byte - if (x + 1 >= static_cast(this->width_)) { - end_of_row = true; - break; - } + size_t x = this->paint_index_ % width; + size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / width); + size_t remaining_in_row = width - x; + uint8_t pixels_in_byte = std::min(remaining_in_row, 8); + bool end_of_row = remaining_in_row <= 8; + size_t needed = 1 + (end_of_row ? this->padding_bytes_ : 0); + if (index + needed > size) { + this->decoded_bytes_ += index; + return index; } + uint8_t current_byte = buffer[index]; + for (uint8_t i = 0; i < pixels_in_byte; i++) { + Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF; + this->draw(x + i, y, 1, 1, c); + } + this->paint_index_ += pixels_in_byte; this->current_index_++; index++; // End of pixel row: skip row padding bytes (4-byte alignment) @@ -116,23 +147,57 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { } break; } - case 24: { + case 8: { + size_t width = static_cast(this->width_); + size_t last_col = width - 1; while (index < size) { - if (index + 2 >= size) { + size_t x = this->paint_index_ % width; + size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / width); + size_t needed = 1 + ((x == last_col) ? this->padding_bytes_ : 0); + if (index + needed > size) { + this->decoded_bytes_ += index; + return index; + } + + uint8_t color_index = buffer[index]; + if (color_index >= this->color_table_entries_) { + ESP_LOGE(TAG, "Invalid color index: %u", color_index); + return DECODE_ERROR_UNSUPPORTED_FORMAT; + } + + uint32_t rgb = this->color_table_[color_index]; + uint8_t b = rgb & 0xff; + uint8_t g = (rgb >> 8) & 0xff; + uint8_t r = (rgb >> 16) & 0xff; + this->draw(x, y, 1, 1, Color(r, g, b)); + this->paint_index_++; + this->current_index_++; + index++; + if (x == last_col && this->padding_bytes_ > 0) { + index += this->padding_bytes_; + this->current_index_ += this->padding_bytes_; + } + } + break; + } + case 24: { + size_t width = static_cast(this->width_); + size_t last_col = width - 1; + while (index < size) { + size_t x = this->paint_index_ % width; + size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / width); + size_t needed = 3 + ((x == last_col) ? this->padding_bytes_ : 0); + if (index + needed > size) { this->decoded_bytes_ += index; return index; } uint8_t b = buffer[index]; uint8_t g = buffer[index + 1]; uint8_t r = buffer[index + 2]; - size_t x = this->paint_index_ % static_cast(this->width_); - size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / static_cast(this->width_)); - Color c = Color(r, g, b); - this->draw(x, y, 1, 1, c); + this->draw(x, y, 1, 1, Color(r, g, b)); this->paint_index_++; this->current_index_ += 3; index += 3; - size_t last_col = static_cast(this->width_) - 1; if (x == last_col && this->padding_bytes_ > 0) { index += this->padding_bytes_; this->current_index_ += this->padding_bytes_; diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 37db6b49405..73e54f54302 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -3,6 +3,9 @@ #include "esphome/core/defines.h" #ifdef USE_RUNTIME_IMAGE_BMP +#include +#include + #include "image_decoder.h" #include "runtime_image.h" @@ -36,6 +39,7 @@ class BmpDecoder : public ImageDecoder { uint32_t compression_method_{0}; uint32_t image_data_size_{0}; uint32_t color_table_entries_{0}; + std::unique_ptr color_table_; size_t width_bytes_{0}; size_t data_offset_{0}; uint8_t padding_bytes_{0}; diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index 422a24b5406..fc3cc942172 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -40,6 +40,10 @@ online_image: url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: BINARY + - id: online_rgb_bmp_8bit + url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp + format: BMP + type: RGB - id: online_jpeg_image url: http://www.faqs.org/images/library.jpg format: JPEG From 8ca6ee4349acb28878d4c15ef32d67999b96f139 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 10 Mar 2026 15:25:26 -0500 Subject: [PATCH 13/44] [speaker_source] Add new media player (#14649) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/speaker_source/__init__.py | 0 .../components/speaker_source/media_player.py | 212 +++++++ .../speaker_source_media_player.cpp | 546 ++++++++++++++++++ .../speaker_source_media_player.h | 217 +++++++ tests/components/speaker_source/common.yaml | 43 ++ .../speaker_source/test.esp32-idf.yaml | 9 + tests/components/speaker_source/test.wav | Bin 0 -> 46 bytes 8 files changed, 1028 insertions(+) create mode 100644 esphome/components/speaker_source/__init__.py create mode 100644 esphome/components/speaker_source/media_player.py create mode 100644 esphome/components/speaker_source/speaker_source_media_player.cpp create mode 100644 esphome/components/speaker_source/speaker_source_media_player.h create mode 100644 tests/components/speaker_source/common.yaml create mode 100644 tests/components/speaker_source/test.esp32-idf.yaml create mode 100644 tests/components/speaker_source/test.wav diff --git a/CODEOWNERS b/CODEOWNERS index a95e100cbff..12aff01e730 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -459,6 +459,7 @@ esphome/components/sonoff_d1/* @anatoly-savchenkov esphome/components/sound_level/* @kahrendt esphome/components/speaker/* @jesserockz @kahrendt esphome/components/speaker/media_player/* @kahrendt @synesthesiam +esphome/components/speaker_source/* @kahrendt esphome/components/spi/* @clydebarrow @esphome/core esphome/components/spi_device/* @clydebarrow esphome/components/spi_led_strip/* @clydebarrow diff --git a/esphome/components/speaker_source/__init__.py b/esphome/components/speaker_source/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py new file mode 100644 index 00000000000..a44cdcbf01e --- /dev/null +++ b/esphome/components/speaker_source/media_player.py @@ -0,0 +1,212 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import audio, media_player, media_source, speaker +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, + CONF_SPEAKER, +) +from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType + +AUTO_LOAD = ["audio"] +DEPENDENCIES = ["media_source", "speaker"] + +CODEOWNERS = ["@kahrendt"] + +CONF_MEDIA_PIPELINE = "media_pipeline" +CONF_ON_MUTE = "on_mute" +CONF_ON_UNMUTE = "on_unmute" +CONF_ON_VOLUME = "on_volume" +CONF_SOURCES = "sources" +CONF_VOLUME_INCREMENT = "volume_increment" +CONF_VOLUME_INITIAL = "volume_initial" +CONF_VOLUME_MAX = "volume_max" +CONF_VOLUME_MIN = "volume_min" + +speaker_source_ns = cg.esphome_ns.namespace("speaker_source") + +SpeakerSourceMediaPlayer = speaker_source_ns.class_( + "SpeakerSourceMediaPlayer", cg.Component, media_player.MediaPlayer +) + +PipelineContext = speaker_source_ns.struct("PipelineContext") + +Pipeline = speaker_source_ns.enum("Pipeline") + + +FORMAT_MAPPING = { + "FLAC": "flac", + "MP3": "mp3", + "OPUS": "opus", + "WAV": "wav", +} + + +# Returns a media_player.MediaPlayerSupportedFormat struct with the configured +# format, sample rate, number of channels, purpose, and bytes per sample +def _get_supported_format_struct(pipeline: ConfigType): + args = [ + media_player.MediaPlayerSupportedFormat, + ] + + args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]])) + + args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) + args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) + args.append(("purpose", media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"])) + + # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails + # if the number of bytes per sample is specified for MP3. + if pipeline[CONF_FORMAT] != "MP3": + args.append(("sample_bytes", 2)) + + return cg.StructInitializer(*args) + + +def _validate_pipeline(config: ConfigType) -> ConfigType: + # Inherit settings from speaker if not manually set + inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) + inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) + + # Opus only supports 48 kHz + if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: + raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") + + audio.final_validate_audio_schema( + "speaker_source media_player", + audio_device=CONF_SPEAKER, + bits_per_sample=16, + channels=config.get(CONF_NUM_CHANNELS), + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +PIPELINE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id( + PipelineContext + ), # Needed to inherit audio settings from the speaker + cv.Required(CONF_SPEAKER): cv.use_id(speaker.Speaker), + cv.Required(CONF_SOURCES): cv.All( + cv.ensure_list(cv.use_id(media_source.MediaSource)), + cv.Length(min=1), + ), + cv.Optional(CONF_FORMAT, default="FLAC"): cv.enum(audio.AUDIO_FILE_TYPE_ENUM), + cv.Optional(CONF_SAMPLE_RATE): cv.int_range(min=1), + cv.Optional(CONF_NUM_CHANNELS): cv.int_range(1, 2), + } +) + + +def _validate_volume_settings(config: ConfigType) -> ConfigType: + # CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated + if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]: + raise cv.Invalid( + f"{CONF_VOLUME_MIN} ({config[CONF_VOLUME_MIN]}) must be less than or equal to {CONF_VOLUME_MAX} ({config[CONF_VOLUME_MAX]})" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, + cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, + cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, + cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage, + cv.Required(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_UNMUTE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)), + cv.only_on_esp32, + _validate_volume_settings, +) + + +def _final_validate_codecs(config: ConfigType) -> ConfigType: + pipeline = config[CONF_MEDIA_PIPELINE] + fmt = pipeline[CONF_FORMAT] + if fmt == "NONE": + audio.request_flac_support() + audio.request_mp3_support() + audio.request_opus_support() + elif fmt == "FLAC": + audio.request_flac_support() + elif fmt == "MP3": + audio.request_mp3_support() + elif fmt == "OPUS": + audio.request_opus_support() + + return config + + +FINAL_VALIDATE_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_MEDIA_PIPELINE): _validate_pipeline, + }, + extra=cv.ALLOW_EXTRA, + ), + _final_validate_codecs, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_player.register_media_player(var, config) + + cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) + cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) + cg.add(var.set_volume_max(config[CONF_VOLUME_MAX])) + cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) + + pipeline_config = config[CONF_MEDIA_PIPELINE] + pipeline_enum = Pipeline.MEDIA_PIPELINE + + for source in pipeline_config[CONF_SOURCES]: + src = await cg.get_variable(source) + cg.add(var.add_media_source(pipeline_enum, src)) + + cg.add( + var.set_speaker( + pipeline_enum, + await cg.get_variable(pipeline_config[CONF_SPEAKER]), + ) + ) + if pipeline_config[CONF_FORMAT] != "NONE": + cg.add( + var.set_format( + pipeline_enum, + _get_supported_format_struct(pipeline_config), + ) + ) + + if on_mute := config.get(CONF_ON_MUTE): + await automation.build_automation( + var.get_mute_trigger(), + [], + on_mute, + ) + if on_unmute := config.get(CONF_ON_UNMUTE): + await automation.build_automation( + var.get_unmute_trigger(), + [], + on_unmute, + ) + if on_volume := config.get(CONF_ON_VOLUME): + await automation.build_automation( + var.get_volume_trigger(), + [(cg.float_, "x")], + on_volume, + ) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp new file mode 100644 index 00000000000..a3679891d2d --- /dev/null +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -0,0 +1,546 @@ +#include "speaker_source_media_player.h" + +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::speaker_source { + +static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; + +static const char *const TAG = "speaker_source_media_player"; + +// SourceBinding method implementations (defined here because SpeakerSourceMediaPlayer is forward-declared in the +// header) + +// THREAD CONTEXT: Called from media source decode task thread +size_t SourceBinding::write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) { + return this->player->handle_media_output_(this->pipeline, this->source, data, length, timeout_ms, stream_info); +} + +// THREAD CONTEXT: Called from main loop (media source's loop() calls set_state_ which calls report_state) +void SourceBinding::report_state(media_source::MediaSourceState state) { + this->player->handle_media_state_changed_(this->pipeline, this->source, state); +} + +// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop +void SourceBinding::request_volume(float volume) { + this->player->defer([this, volume]() { this->player->handle_volume_request_(volume); }); +} + +// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop +void SourceBinding::request_mute(bool is_muted) { + this->player->defer([this, is_muted]() { this->player->handle_mute_request_(is_muted); }); +} + +// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop +void SourceBinding::request_play_uri(const std::string &uri) { + this->player->defer([this, uri]() { this->player->handle_play_uri_request_(this->pipeline, uri); }); +} + +// THREAD CONTEXT: Called during code generation setup (main loop) +void SpeakerSourceMediaPlayer::add_media_source(uint8_t pipeline, media_source::MediaSource *media_source) { + auto &binding = + this->pipelines_[pipeline].sources.emplace_back(std::make_unique(this, media_source, pipeline)); + media_source->set_listener(binding.get()); +} + +void SpeakerSourceMediaPlayer::dump_config() { + ESP_LOGCONFIG(TAG, + "Speaker Source Media Player:\n" + " Volume Increment: %.2f\n" + " Volume Min: %.2f\n" + " Volume Max: %.2f", + this->volume_increment_, this->volume_min_, this->volume_max_); +} + +void SpeakerSourceMediaPlayer::setup() { + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + + this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaPlayerControlCommand)); + + this->pref_ = this->make_entity_preference(); + + VolumeRestoreState volume_restore_state; + if (this->pref_.load(&volume_restore_state)) { + this->set_volume_(volume_restore_state.volume); + this->set_mute_state_(volume_restore_state.is_muted); + } else { + this->set_volume_(this->volume_initial_); + this->set_mute_state_(false); + } + + // Register callbacks to receive playback notifications from speakers + for (size_t i = 0; i < this->pipelines_.size(); i++) { + if (this->pipelines_[i].is_configured()) { + this->pipelines_[i].speaker->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp) { + this->handle_speaker_playback_callback_(frames, timestamp, i); + }); + } + } +} + +// THREAD CONTEXT: Called from the speaker's playback callback task (not main loop) +void SpeakerSourceMediaPlayer::handle_speaker_playback_callback_(uint32_t frames, int64_t timestamp, uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + // Load once so the null check and use below are consistent + media_source::MediaSource *active_source = ps.active_source.load(std::memory_order_relaxed); + if (active_source == nullptr) { + return; + } + + // CAS loop to safely subtract frames without underflow. If pending_frames is reset to 0 (new source + // starting) between the load and the subtract, compare_exchange_weak will fail and reload the current value. + uint32_t current = ps.pending_frames.load(std::memory_order_relaxed); + uint32_t source_frames; + do { + source_frames = std::min(frames, current); + } while (source_frames > 0 && + !ps.pending_frames.compare_exchange_weak(current, current - source_frames, std::memory_order_relaxed)); + + if (source_frames > 0) { + // Notify the source about the played audio + active_source->notify_audio_played(source_frames, timestamp); + } +} + +// THREAD CONTEXT: Called from main loop via defer() +void SpeakerSourceMediaPlayer::handle_volume_request_(float volume) { + // Update the media player's volume + this->set_volume_(volume); + this->publish_state(); +} + +// THREAD CONTEXT: Called from main loop via defer() +void SpeakerSourceMediaPlayer::handle_mute_request_(bool is_muted) { + // Update the media player's mute state + this->set_mute_state_(is_muted); + this->publish_state(); +} + +// THREAD CONTEXT: Called from main loop via defer() +void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const std::string &uri) { + // Smart source is requesting the player to play a different URI + auto call = this->make_call(); + call.set_media_url(uri); + call.perform(); +} + +// THREAD CONTEXT: Called from main loop (media source's loop() calls set_state_ which calls report_state) +void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, media_source::MediaSource *source, + media_source::MediaSourceState state) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (state == media_source::MediaSourceState::IDLE) { + // Source went idle - clear stopping flag if this was the source we asked to stop + if (ps.stopping_source == source) { + ps.stopping_source = nullptr; + } + + // Clear pending flag if this was the source we asked to play + if (ps.pending_source == source) { + ps.pending_source = nullptr; + } + + // Source went idle - clear it if it's the active source + if (ps.active_source == source) { + ps.last_source = ps.active_source; + ps.active_source = nullptr; + + // Finish the speaker to ensure it's ready for the next playback + ps.speaker->finish(); + } + } else if (state == media_source::MediaSourceState::PLAYING) { + // Source started playing - make it the active source if no one else is active + if (ps.active_source == nullptr) { + ps.active_source = source; + ps.last_source = nullptr; + + // Clear pending flag now that the source is active + if (ps.pending_source == source) { + ps.pending_source = nullptr; + } + } + } +} + +// THREAD CONTEXT: Called from media source decode task thread (not main loop). +// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls +// ps.speaker methods (speaker pointer is immutable after setup). +size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, + const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) { + PipelineContext &ps = this->pipelines_[pipeline]; + + // Single read; the if-body only uses ps.speaker (immutable after setup) and the source parameter. + if (ps.active_source == source) { + // This source is active - play the audio + if (ps.speaker->get_audio_stream_info() != stream_info) { + // Setup the speaker to play this stream + ps.speaker->set_audio_stream_info(stream_info); + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms)); + if (bytes_written > 0) { + // Track frames sent to speaker for this source + ps.pending_frames.fetch_add(stream_info.bytes_to_frames(bytes_written), std::memory_order_relaxed); + } + return bytes_written; + } + + // Not the active source - wait for state callback to set us as active when we transition to PLAYING + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; +} + +media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_( + media_source::MediaSource *source) const { + if (source != nullptr) { + switch (source->get_state()) { + case media_source::MediaSourceState::PLAYING: + return media_player::MEDIA_PLAYER_STATE_PLAYING; + case media_source::MediaSourceState::PAUSED: + return media_player::MEDIA_PLAYER_STATE_PAUSED; + case media_source::MediaSourceState::ERROR: + ESP_LOGE(TAG, "Source error"); + return media_player::MEDIA_PLAYER_STATE_IDLE; + case media_source::MediaSourceState::IDLE: + default: + return media_player::MEDIA_PLAYER_STATE_IDLE; + } + } + + return media_player::MEDIA_PLAYER_STATE_IDLE; +} + +void SpeakerSourceMediaPlayer::loop() { + // Process queued control commands + MediaPlayerControlCommand control_command; + + // Use peek to check command without removing it + if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) == pdTRUE) { + bool command_executed = false; + uint8_t pipeline = control_command.pipeline; + + switch (control_command.type) { + case MediaPlayerControlCommand::PLAY_URI: { + command_executed = this->try_execute_play_uri_(*control_command.data.uri, pipeline); + break; + } + + case MediaPlayerControlCommand::SEND_COMMAND: { + PipelineContext &ps = this->pipelines_[pipeline]; + + // Determine target source: prefer active, fall back to last + media_source::MediaSource *target_source = nullptr; + if (ps.active_source != nullptr) { + target_source = ps.active_source; + } else if (ps.last_source != nullptr) { + target_source = ps.last_source; + } + + media_player::MediaPlayerCommand player_command = control_command.data.command; + switch (player_command) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: { + media_source::MediaSource *active_source = ps.active_source; + if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + } else { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PLAY: { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_STOP: { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::STOP); + } + break; + } + + default: + break; + } + + command_executed = true; + break; + } + } + + // Only remove from queue if successfully executed + if (command_executed) { + xQueueReceive(this->media_control_command_queue_, &control_command, 0); + + // Delete the allocated string for PLAY_URI commands + if (control_command.type == MediaPlayerControlCommand::PLAY_URI) { + delete control_command.data.uri; + } + } + } + + // Update state based on active sources + media_player::MediaPlayerState old_state = this->state; + + PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE]; + this->state = this->get_media_pipeline_state_(media_ps.active_source); + + if (this->state != old_state) { + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } +} + +media_source::MediaSource *SpeakerSourceMediaPlayer::find_source_for_uri_(const std::string &uri, uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *first_match = nullptr; + for (auto &binding : ps.sources) { + if (binding->source->can_handle(uri)) { + // Prefer an idle source; otherwise remember the first match (will be stopped by try_execute_play_uri_) + if (binding->source->get_state() == media_source::MediaSourceState::IDLE) { + return binding->source; + } + if (first_match == nullptr) { + first_match = binding->source; + } + } + } + return first_match; +} + +bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uint8_t pipeline) { + // Find target source + media_source::MediaSource *target_source = this->find_source_for_uri_(uri, pipeline); + if (target_source == nullptr) { + ESP_LOGW(TAG, "No source for URI"); + ESP_LOGV(TAG, "URI: %s", uri.c_str()); + return true; // Remove from queue (unrecoverable) + } + + PipelineContext &ps = this->pipelines_[pipeline]; + + media_source::MediaSource *active_source = ps.active_source; + + // If active source exists and is not IDLE, stop it and wait + if (active_source != nullptr) { + media_source::MediaSourceState active_state = active_source->get_state(); + if (active_state != media_source::MediaSourceState::IDLE) { + // Only send END command once per source - check if we've already asked this source to stop + if (ps.stopping_source != active_source) { + ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline); + active_source->handle_command(media_source::MediaSourceCommand::STOP); + ps.speaker->stop(); + ps.stopping_source = active_source; + } + return false; // Leave in queue, retry next loop + } + } + + // Also check target source directly - handles case where source errored before PLAYING state + media_source::MediaSourceState target_state = target_source->get_state(); + if (target_state != media_source::MediaSourceState::IDLE) { + // Only send STOP command once per source + if (ps.stopping_source != target_source) { + ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline); + target_source->handle_command(media_source::MediaSourceCommand::STOP); + ps.speaker->stop(); + ps.stopping_source = target_source; + } + return false; // Leave in queue, retry next loop + } + + // Clear stopping flag since we're past the stopping phase + ps.stopping_source = nullptr; + + // Check if speaker is ready + if (!ps.speaker->is_stopped()) { + return false; // Speaker not ready yet, retry later + } + + // Set pending source so handle_media_state_changed_ can recognize it when the source transitions to PLAYING + ps.pending_source = target_source; + + // Speaker is ready, try to play + if (!target_source->play_uri(uri)) { + ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str()); + ps.pending_source = nullptr; + } + + // Reset pending frame counter for this pipeline since we're starting a new source + ps.pending_frames.store(0, std::memory_order_relaxed); + + return true; // Remove from queue +} + +// THREAD CONTEXT: Called from main loop only. Entry points: +// - HA/automation commands (direct) +// - handle_play_uri_request_() via make_call().perform() (deferred from source tasks) +void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call) { + if (!this->is_ready()) { + return; + } + + MediaPlayerControlCommand control_command; + control_command.pipeline = MEDIA_PIPELINE; + + auto media_url = call.get_media_url(); + if (media_url.has_value()) { + control_command.type = MediaPlayerControlCommand::PLAY_URI; + // Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens + // can easily exceed 500 bytes). Deleted after the command is consumed. FreeRTOS queues require items to be + // copyable, so we store a pointer to the string in the queue rather than the string itself. + control_command.data.uri = new std::string(media_url.value()); + if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + delete control_command.data.uri; + ESP_LOGE(TAG, "Queue full, URI dropped"); + } + return; + } + + auto volume = call.get_volume(); + if (volume.has_value()) { + this->set_volume_(volume.value()); + this->publish_state(); + return; + } + + auto cmd = call.get_command(); + if (cmd.has_value()) { + switch (cmd.value()) { + case media_player::MEDIA_PLAYER_COMMAND_MUTE: + this->set_mute_state_(true); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNMUTE: + this->set_mute_state_(false); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP: + this->set_volume_(std::min(1.0f, this->volume + this->volume_increment_)); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: + this->set_volume_(std::max(0.0f, this->volume - this->volume_increment_)); + break; + default: + // Queue command for processing in loop() + control_command.type = MediaPlayerControlCommand::SEND_COMMAND; + control_command.data.command = cmd.value(); + if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + ESP_LOGE(TAG, "Queue full, command dropped"); + } + return; + } + this->publish_state(); + } +} + +media_player::MediaPlayerTraits SpeakerSourceMediaPlayer::get_traits() { + auto traits = media_player::MediaPlayerTraits(); + traits.set_supports_pause(true); + + for (const auto &ps : this->pipelines_) { + if (ps.format.has_value()) { + traits.get_supported_formats().push_back(ps.format.value()); + } + } + + return traits; +} + +void SpeakerSourceMediaPlayer::save_volume_restore_state_() { + VolumeRestoreState volume_restore_state; + volume_restore_state.volume = this->volume; + volume_restore_state.is_muted = this->is_muted_; + this->pref_.save(&volume_restore_state); +} + +void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { + if (this->is_muted_ == mute_state) { + return; + } + + for (auto &ps : this->pipelines_) { + if (ps.is_configured()) { + ps.speaker->set_mute_state(mute_state); + } + } + + this->is_muted_ = mute_state; + + if (publish) { + this->save_volume_restore_state_(); + } + + // Notify all media sources about the mute state change + for (auto &ps : this->pipelines_) { + for (auto &binding : ps.sources) { + binding->source->notify_mute_changed(mute_state); + } + } + + if (mute_state) { + this->defer([this]() { this->mute_trigger_.trigger(); }); + } else { + this->defer([this]() { this->unmute_trigger_.trigger(); }); + } +} + +void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { + // Remap the volume to fit within the configured limits + float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + + for (auto &ps : this->pipelines_) { + if (ps.is_configured()) { + ps.speaker->set_volume(bounded_volume); + } + } + + if (publish) { + this->volume = volume; + } + + // Notify all media sources about the volume change + for (auto &ps : this->pipelines_) { + for (auto &binding : ps.sources) { + binding->source->notify_volume_changed(volume); + } + } + + // Turn on the mute state if the volume is effectively zero, off otherwise. + // Pass publish=false to avoid saving twice. + if (volume < 0.001) { + this->set_mute_state_(true, false); + } else { + this->set_mute_state_(false, false); + } + + // Save after mute mutation so the restored state has the correct is_muted_ value + if (publish) { + this->save_volume_restore_state_(); + } + + this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); +} + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h new file mode 100644 index 00000000000..7896fef295a --- /dev/null +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -0,0 +1,217 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/audio/audio.h" +#include "esphome/components/media_source/media_source.h" +#include "esphome/components/media_player/media_player.h" +#include "esphome/components/speaker/speaker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" + +#include +#include +#include +#include +#include +#include + +namespace esphome::speaker_source { + +// THREADING MODEL: +// This component coordinates media sources that run their own decode tasks with speakers +// that have their own playback callback tasks. Three thread contexts exist: +// +// - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), +// handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), +// set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(), +// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_() +// +// - Media source task(s): handle_media_output_() via SourceBinding::write_audio(). +// Called from each source's decode task thread when streaming audio data. +// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls +// ps.speaker methods (speaker pointer is immutable after setup). +// +// - Speaker callback task: handle_speaker_playback_callback_() via speaker's +// add_audio_output_callback(). Called when the speaker finishes writing frames to the DAC. +// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls +// active_source->notify_audio_played(). +// +// control() is only called from the main loop (HA/automation commands). +// Source tasks use defer() for all requests (volume, mute, play_uri). +// +// Thread-safe communication: +// - FreeRTOS queue (media_control_command_queue_): control() -> loop() for play/command dispatch +// - defer(): SourceBinding::request_volume/request_mute/request_play_uri -> main loop +// - Atomic fields (active_source, pending_frames): shared between all three thread contexts +// +// Non-atomic pipeline fields (last_source, stopping_source, pending_source) are only accessed +// from the main loop thread. + +enum Pipeline : uint8_t { + MEDIA_PIPELINE = 0, +}; + +// Forward declaration +class SpeakerSourceMediaPlayer; + +/// @brief Per-source listener binding that captures the source pointer at registration time. +/// Each binding implements MediaSourceListener and forwards callbacks to the player with the source identified. +/// Defined before PipelineContext so pipelines can own their bindings directly. +struct SourceBinding : public media_source::MediaSourceListener { + SourceBinding(SpeakerSourceMediaPlayer *player, media_source::MediaSource *source, uint8_t pipeline) + : player(player), source(source), pipeline(pipeline) {} + SpeakerSourceMediaPlayer *player; + media_source::MediaSource *source; + uint8_t pipeline; + + // Implementations are in the .cpp file because SpeakerSourceMediaPlayer is only forward-declared here + size_t write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) override; + void report_state(media_source::MediaSourceState state) override; + void request_volume(float volume) override; + void request_mute(bool is_muted) override; + void request_play_uri(const std::string &uri) override; +}; + +struct PipelineContext { + speaker::Speaker *speaker{nullptr}; + optional format; + + std::atomic active_source{nullptr}; + media_source::MediaSource *last_source{nullptr}; + media_source::MediaSource *stopping_source{nullptr}; // Source we've asked to stop, awaiting IDLE + media_source::MediaSource *pending_source{nullptr}; // Source we've asked to play, awaiting PLAYING + + // Each SourceBinding pairs a MediaSource* with its listener implementation. + // Uses unique_ptr so binding addresses are stable and set_listener() can be called in add_media_source(). + // Uses std::vector because the count varies across instances (multiple speaker_source media players may exist). + std::vector> sources; + + // Track frames sent to speaker to correlate with playback callbacks. + // Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback + // callback. + std::atomic pending_frames{0}; + + /// @brief Check if this pipeline is configured (has a speaker assigned) + bool is_configured() const { return this->speaker != nullptr; } +}; + +struct MediaPlayerControlCommand { + enum Type : uint8_t { + PLAY_URI, // Find a source that can handle this URI and play it + SEND_COMMAND, // Send command to active source + }; + Type type; + uint8_t pipeline; + + union { + std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI) + media_player::MediaPlayerCommand command; + } data; +}; + +struct VolumeRestoreState { + float volume; + bool is_muted; +}; + +class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPlayer { + friend struct SourceBinding; + + public: + float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } + void setup() override; + void loop() override; + void dump_config() override; + + // MediaPlayer implementations + media_player::MediaPlayerTraits get_traits() override; + bool is_muted() const override { return this->is_muted_; } + + // Percentage to increase or decrease the volume for volume up or volume down commands + void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; } + + // Volume used initially on first boot when no volume had been previously saved + void set_volume_initial(float volume_initial) { this->volume_initial_ = volume_initial; } + + void set_volume_max(float volume_max) { this->volume_max_ = volume_max; } + void set_volume_min(float volume_min) { this->volume_min_ = volume_min; } + + /// @brief Adds a media source to a pipeline and registers this player as its listener + void add_media_source(uint8_t pipeline, media_source::MediaSource *media_source); + + void set_speaker(uint8_t pipeline, speaker::Speaker *speaker) { this->pipelines_[pipeline].speaker = speaker; } + void set_format(uint8_t pipeline, const media_player::MediaPlayerSupportedFormat &format) { + this->pipelines_[pipeline].format = format; + } + + Trigger<> *get_mute_trigger() { return &this->mute_trigger_; } + Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } + Trigger *get_volume_trigger() { return &this->volume_trigger_; } + + protected: + // Callbacks from source bindings (pipeline index is captured at binding creation time) + size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length, + uint32_t timeout_ms, const audio::AudioStreamInfo &stream_info); + void handle_media_state_changed_(uint8_t pipeline, media_source::MediaSource *source, + media_source::MediaSourceState state); + void handle_volume_request_(float volume); + void handle_mute_request_(bool is_muted); + void handle_play_uri_request_(uint8_t pipeline, const std::string &uri); + + void handle_speaker_playback_callback_(uint32_t frames, int64_t timestamp, uint8_t pipeline); + + // Receives commands from HA or from the voice assistant component + // Sends commands to the media_control_command_queue_ + void control(const media_player::MediaPlayerCall &call) override; + + /// @brief Updates this->volume and saves volume/mute state to flash for restoration if publish is true. + void set_volume_(float volume, bool publish = true); + + /// @brief Sets the mute state. + /// @param mute_state If true, audio will be muted. If false, audio will be unmuted + /// @param publish If true, saves volume/mute state to flash for restoration + void set_mute_state_(bool mute_state, bool publish = true); + + /// @brief Saves the current volume and mute state to the flash for restoration. + void save_volume_restore_state_(); + + /// @brief Determine media player state from the media pipeline's active source + /// @param media_source Active source for the media pipeline (may be nullptr) + /// @return The appropriate MediaPlayerState + media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source) const; + + bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline); + media_source::MediaSource *find_source_for_uri_(const std::string &uri, uint8_t pipeline); + QueueHandle_t media_control_command_queue_; + + // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. + std::array pipelines_; + + // Used to save volume/mute state for restoration on reboot + ESPPreferenceObject pref_; + + Trigger<> mute_trigger_; + Trigger<> unmute_trigger_; + Trigger volume_trigger_; + + // The amount to change the volume on volume up/down commands + float volume_increment_; + + // The initial volume used by Setup when no previous volume was saved + float volume_initial_; + + float volume_max_; + float volume_min_; + + bool is_muted_{false}; +}; + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml new file mode 100644 index 00000000000..cfcb065f57c --- /dev/null +++ b/tests/components/speaker_source/common.yaml @@ -0,0 +1,43 @@ +i2s_audio: + i2s_lrclk_pin: ${i2s_bclk_pin} + i2s_bclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} + +speaker: + - platform: i2s_audio + id: speaker_id + dac_type: external + i2s_dout_pin: ${i2s_dout_pin} + sample_rate: 48000 + num_channels: 2 + +audio_file: + - id: test_audio + file: + type: local + path: $component_dir/test.wav + +media_source: + - platform: audio_file + id: audio_file_source + +media_player: + - platform: speaker_source + id: media_player_id + name: Media Player + volume_increment: 0.02 + volume_initial: 0.75 + volume_max: 0.95 + volume_min: 0.0 + media_pipeline: + speaker: speaker_id + format: FLAC + num_channels: 1 + sources: + - audio_file_source + on_mute: + - media_player.pause: + id: media_player_id + on_unmute: + - media_player.play: + id: media_player_id diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml new file mode 100644 index 00000000000..e2439ebdf21 --- /dev/null +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO23 + +<<: !include common.yaml diff --git a/tests/components/speaker_source/test.wav b/tests/components/speaker_source/test.wav new file mode 100644 index 0000000000000000000000000000000000000000..f9d07ef2238eb2fcb355055466d3789ee1a1fe0b GIT binary patch literal 46 ycmWIYbaPW Date: Tue, 10 Mar 2026 16:38:50 -0400 Subject: [PATCH 14/44] [config] Allow !extend/!remove on components without id in schema (#14682) Co-authored-by: Claude Opus 4.6 --- esphome/voluptuous_schema.py | 6 ++ .../external_components/common.yaml | 6 +- .../external_components/test.esp32-idf.yaml | 4 + tests/unit_tests/test_voluptuous_schema.py | 86 +++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_voluptuous_schema.py diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 7220fb307ff..0703c54a7a8 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -175,6 +175,12 @@ class _Schema(vol.Schema): else: if self.extra == vol.ALLOW_EXTRA: out[key] = value + elif key == "id": + # Silently drop 'id' on any dict so that + # !extend / !remove work on every list-based + # config without requiring each component to + # declare an id in its schema. + pass elif self.extra != vol.REMOVE_EXTRA: if isinstance(key, str) and key_names: matches = difflib.get_close_matches(key, key_names) diff --git a/tests/components/external_components/common.yaml b/tests/components/external_components/common.yaml index 2b51267ec68..c55129094c4 100644 --- a/tests/components/external_components/common.yaml +++ b/tests/components/external_components/common.yaml @@ -1,6 +1,8 @@ external_components: - - source: github://esphome/esphome@dev + - id: my_ext + source: github://esphome/esphome@dev refresh: 1d components: [bh1750] - - source: ../../../esphome/components + - id: my_local + source: ../../../esphome/components components: [sntp] diff --git a/tests/components/external_components/test.esp32-idf.yaml b/tests/components/external_components/test.esp32-idf.yaml index dade44d145b..afe2bd5d6ae 100644 --- a/tests/components/external_components/test.esp32-idf.yaml +++ b/tests/components/external_components/test.esp32-idf.yaml @@ -1 +1,5 @@ +# WARNING: Using !extend or !remove prevents automatic component grouping in CI, making builds slower. <<: !include common.yaml + +external_components: + - id: !remove my_local diff --git a/tests/unit_tests/test_voluptuous_schema.py b/tests/unit_tests/test_voluptuous_schema.py new file mode 100644 index 00000000000..21c7decede1 --- /dev/null +++ b/tests/unit_tests/test_voluptuous_schema.py @@ -0,0 +1,86 @@ +"""Tests for voluptuous_schema.py.""" + +import pytest +import voluptuous as vol + +from esphome.voluptuous_schema import _Schema + + +class TestIdKeyDropping: + """Test that 'id' keys are silently dropped in PREVENT_EXTRA schemas.""" + + def test_id_key_silently_dropped(self): + """Schema without 'id' should accept and drop 'id' key from input.""" + schema = _Schema( + { + vol.Required("name"): str, + vol.Optional("value", default=0): int, + } + ) + result = schema({"name": "test", "value": 42, "id": "my_id"}) + assert result == {"name": "test", "value": 42} + assert "id" not in result + + def test_id_key_dropped_with_only_required(self): + """Schema with only required keys should still drop 'id'.""" + schema = _Schema( + { + vol.Required("source"): str, + } + ) + result = schema({"source": "github://test", "id": "my_component"}) + assert result == {"source": "github://test"} + + def test_other_extra_keys_still_rejected(self): + """Non-'id' extra keys should still raise errors.""" + schema = _Schema( + { + vol.Required("name"): str, + } + ) + with pytest.raises(vol.MultipleInvalid, match="extra keys not allowed"): + schema({"name": "test", "unknown_key": "value"}) + + def test_id_key_not_dropped_when_in_schema(self): + """When 'id' is declared in the schema, it should be validated normally.""" + schema = _Schema( + { + vol.Required("id"): str, + vol.Required("name"): str, + } + ) + result = schema({"id": "my_id", "name": "test"}) + assert result == {"id": "my_id", "name": "test"} + + def test_id_key_not_dropped_with_allow_extra(self): + """With ALLOW_EXTRA, 'id' should be kept (not dropped).""" + schema = _Schema( + { + vol.Required("name"): str, + }, + extra=vol.ALLOW_EXTRA, + ) + result = schema({"name": "test", "id": "my_id"}) + assert result == {"name": "test", "id": "my_id"} + + def test_id_key_dropped_with_remove_extra(self): + """With REMOVE_EXTRA, 'id' should be removed along with other extras.""" + schema = _Schema( + { + vol.Required("name"): str, + }, + extra=vol.REMOVE_EXTRA, + ) + result = schema({"name": "test", "id": "my_id", "other": "value"}) + assert result == {"name": "test"} + + def test_without_id_no_extra_keys(self): + """Normal validation without 'id' key should work as before.""" + schema = _Schema( + { + vol.Required("name"): str, + vol.Optional("value", default=0): int, + } + ) + result = schema({"name": "test"}) + assert result == {"name": "test", "value": 0} From 6356e3def9dce68f3e815d5bd58d2c42ef4ccb7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 10:42:38 -1000 Subject: [PATCH 15/44] [core] Warn on crystal frequency mismatch during serial upload (#14582) Co-authored-by: Claude Opus 4.6 --- esphome/__main__.py | 58 ++++++++++- esphome/util.py | 45 +++++++-- tests/unit_tests/test_main.py | 124 ++++++++++++++++++++++++ tests/unit_tests/test_util.py | 175 ++++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index f33e7f4b426..3f0da85a694 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1,6 +1,7 @@ # PYTHON_ARGCOMPLETE_OK import argparse from collections.abc import Callable +from contextlib import suppress from datetime import datetime import functools import getpass @@ -687,6 +688,47 @@ def _check_and_emit_build_info() -> None: ) +def _get_configured_xtal_freq() -> int | None: + """Read the configured crystal frequency from the sdkconfig file.""" + sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + if not sdkconfig_path.is_file(): + return None + with suppress(OSError, ValueError): + content = sdkconfig_path.read_text() + for line in content.splitlines(): + if line.startswith("CONFIG_XTAL_FREQ="): + return int(line.split("=", 1)[1]) + return None + + +def _make_crystal_freq_callback( + configured_freq: int, +) -> Callable[[str], str | None]: + """Create a callback that checks esptool crystal frequency output.""" + crystal_re = re.compile(r"Crystal frequency:\s+(\d+)\s*MHz") + + def check_crystal_line(line: str) -> str | None: + if not (match := crystal_re.search(line)): + return None + detected = int(match.group(1)) + if detected == configured_freq: + return None + return ( + f"\n\033[33mWARNING: Crystal frequency mismatch! " + f"Device reports {detected}MHz but firmware is configured " + f"for {configured_freq}MHz.\n" + f"UART logging and other clock-dependent features will not " + f"work correctly.\n" + f"Set the correct crystal frequency with sdkconfig_options:\n" + f" esp32:\n" + f" framework:\n" + f" sdkconfig_options:\n" + f" CONFIG_XTAL_FREQ_{detected}: 'y'\033[0m\n\n" + ) + + return check_crystal_line + + def upload_using_esptool( config: ConfigType, port: str, file: str, speed: int ) -> str | int: @@ -715,6 +757,14 @@ def upload_using_esptool( mcu = get_esp32_variant().lower() + line_callbacks: list[Callable[[str], str | None]] = [] + if ( + CORE.is_esp32 + and file is None + and (configured_freq := _get_configured_xtal_freq()) is not None + ): + line_callbacks.append(_make_crystal_freq_callback(configured_freq)) + def run_esptool(baud_rate): cmd = [ "esptool", @@ -739,9 +789,13 @@ def upload_using_esptool( if os.environ.get("ESPHOME_USE_SUBPROCESS") is None: import esptool - return run_external_command(esptool.main, *cmd) # pylint: disable=no-member + return run_external_command( + esptool.main, # pylint: disable=no-member + *cmd, + line_callbacks=line_callbacks, + ) - return run_external_process(*cmd) + return run_external_process(*cmd, line_callbacks=line_callbacks) rc = run_esptool(first_baudrate) if rc == 0 or first_baudrate == 115200: diff --git a/esphome/util.py b/esphome/util.py index 6a21b4f627f..73cc3aa5ab6 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -125,7 +125,12 @@ ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") class RedirectText: - def __init__(self, out, filter_lines=None): + def __init__( + self, + out, + filter_lines: list[str] | None = None, + line_callbacks: list[Callable[[str], str | None]] | None = None, + ) -> None: self._out = out if filter_lines is None: self._filter_pattern = None @@ -133,6 +138,7 @@ class RedirectText: pattern = r"|".join(r"(?:" + pattern + r")" for pattern in filter_lines) self._filter_pattern = re.compile(pattern) self._line_buffer = "" + self._line_callbacks = line_callbacks or [] def __getattr__(self, item): return getattr(self._out, item) @@ -157,7 +163,7 @@ class RedirectText: if not isinstance(s, str): s = s.decode() - if self._filter_pattern is not None: + if self._filter_pattern is not None or self._line_callbacks: self._line_buffer += s lines = self._line_buffer.splitlines(True) for line in lines: @@ -169,7 +175,10 @@ class RedirectText: line_without_ansi = ANSI_ESCAPE.sub("", line) line_without_end = line_without_ansi.rstrip() - if self._filter_pattern.match(line_without_end) is not None: + if ( + self._filter_pattern is not None + and self._filter_pattern.match(line_without_end) is not None + ): # Filter pattern matched, ignore the line continue @@ -181,6 +190,9 @@ class RedirectText: and (help_msg := get_esp32_arduino_flash_error_help()) ): self._write_color_replace(help_msg) + for callback in self._line_callbacks: + if msg := callback(line_without_end): + self._write_color_replace(msg) else: self._write_color_replace(s) @@ -194,7 +206,11 @@ class RedirectText: def run_external_command( - func, *cmd, capture_stdout: bool = False, filter_lines: str = None + func, + *cmd, + capture_stdout: bool = False, + filter_lines: list[str] | None = None, + line_callbacks: list[Callable[[str], str | None]] | None = None, ) -> int | str: """ Run a function from an external package that acts like a main method. @@ -204,7 +220,9 @@ def run_external_command( :param func: Function to execute :param cmd: Command to run as (eg first element of sys.argv) :param capture_stdout: Capture text from stdout and return that. - :param filter_lines: Regular expression used to filter captured output. + Note: line_callbacks are not invoked when capture_stdout is True. + :param filter_lines: Regular expressions used to filter captured output. + :param line_callbacks: Callbacks invoked per line; non-None returns are written to output. :return: str if `capture_stdout` is set else int exit code. """ @@ -218,9 +236,13 @@ def run_external_command( _LOGGER.debug("Running: %s", full_cmd) orig_stdout = sys.stdout - sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + sys.stdout = RedirectText( + sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks + ) orig_stderr = sys.stderr - sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + sys.stderr = RedirectText( + sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks + ) if capture_stdout: cap_stdout = sys.stdout = io.StringIO() @@ -254,14 +276,19 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") + line_callbacks = kwargs.get("line_callbacks") capture_stdout = kwargs.get("capture_stdout", False) if capture_stdout: sub_stdout = subprocess.PIPE else: - sub_stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + sub_stdout = RedirectText( + sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks + ) - sub_stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + sub_stderr = RedirectText( + sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks + ) try: proc = subprocess.run( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b6f1a28086a..b8534611517 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6,6 +6,7 @@ from collections.abc import Generator from dataclasses import dataclass import json import logging +import os from pathlib import Path import re import sys @@ -19,6 +20,8 @@ from pytest import CaptureFixture from esphome import platformio_api from esphome.__main__ import ( Purpose, + _get_configured_xtal_freq, + _make_crystal_freq_callback, choose_upload_log_host, command_analyze_memory, command_clean_all, @@ -3717,3 +3720,124 @@ esp32: clean_output.split("SUMMARY")[1] if "SUMMARY" in clean_output else "" ) assert "secrets.yaml" not in summary_section + + +def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: + """Test reading XTAL_FREQ from sdkconfig.""" + CORE.name = "test-device" + CORE.build_path = tmp_path + sdkconfig = tmp_path / "sdkconfig.test-device" + sdkconfig.write_text( + "CONFIG_SOC_XTAL_SUPPORT_26M=y\nCONFIG_XTAL_FREQ=26\nCONFIG_XTAL_FREQ_26=y\n" + ) + assert _get_configured_xtal_freq() == 26 + + +def test_get_configured_xtal_freq_default_40(tmp_path: Path) -> None: + """Test reading default 40MHz XTAL_FREQ from sdkconfig.""" + CORE.name = "test-device" + CORE.build_path = tmp_path + sdkconfig = tmp_path / "sdkconfig.test-device" + sdkconfig.write_text("CONFIG_XTAL_FREQ=40\nCONFIG_XTAL_FREQ_40=y\n") + assert _get_configured_xtal_freq() == 40 + + +def test_get_configured_xtal_freq_missing_file(tmp_path: Path) -> None: + """Test that missing sdkconfig returns None.""" + CORE.name = "test-device" + CORE.build_path = tmp_path + assert _get_configured_xtal_freq() is None + + +def test_get_configured_xtal_freq_no_xtal_line(tmp_path: Path) -> None: + """Test that sdkconfig without XTAL_FREQ returns None.""" + CORE.name = "test-device" + CORE.build_path = tmp_path + sdkconfig = tmp_path / "sdkconfig.test-device" + sdkconfig.write_text("CONFIG_OTHER=123\n") + assert _get_configured_xtal_freq() is None + + +def test_crystal_freq_callback_mismatch() -> None: + """Test callback returns warning on crystal frequency mismatch.""" + callback = _make_crystal_freq_callback(40) + result = callback("Crystal frequency: 26MHz") + assert result is not None + assert "26MHz" in result + assert "40MHz" in result + assert "CONFIG_XTAL_FREQ_26" in result + + +def test_crystal_freq_callback_match() -> None: + """Test callback returns None when frequencies match.""" + callback = _make_crystal_freq_callback(40) + result = callback("Crystal frequency: 40MHz") + assert result is None + + +def test_crystal_freq_callback_no_crystal_line() -> None: + """Test callback returns None for unrelated lines.""" + callback = _make_crystal_freq_callback(40) + assert callback("Chip type: ESP8684H") is None + assert callback("MAC: a0:b7:65:8b:16:d4") is None + assert callback("") is None + + +def test_upload_using_esptool_passes_crystal_callback( + tmp_path: Path, + mock_run_external_command_main: Mock, + mock_get_idedata: Mock, +) -> None: + """Test that upload_using_esptool passes crystal freq callback for ESP32.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + # Create sdkconfig with XTAL_FREQ + build_dir = Path(CORE.build_path) + build_dir.mkdir(parents=True, exist_ok=True) + sdkconfig = build_dir / "sdkconfig.test" + sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n") + + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [] + mock_get_idedata.return_value = mock_idedata + (tmp_path / "firmware.bin").touch() + + config = {CONF_ESPHOME: {"platformio_options": {}}} + upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + # Verify line_callbacks was passed with the crystal callback + call_kwargs = mock_run_external_command_main.call_args[1] + assert "line_callbacks" in call_kwargs + assert len(call_kwargs["line_callbacks"]) == 1 + + +def test_upload_using_esptool_subprocess_passes_crystal_callback( + mock_run_external_process: Mock, + mock_get_idedata: Mock, + tmp_path: Path, +) -> None: + """Test that crystal freq callback is passed via run_external_process.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + # Create sdkconfig with XTAL_FREQ + build_dir = Path(CORE.build_path) + build_dir.mkdir(parents=True, exist_ok=True) + sdkconfig = build_dir / "sdkconfig.test" + sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n") + + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [] + mock_get_idedata.return_value = mock_idedata + (tmp_path / "firmware.bin").touch() + + config = {CONF_ESPHOME: {"platformio_options": {}}} + with patch.dict(os.environ, {"ESPHOME_USE_SUBPROCESS": "1"}): + upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + call_kwargs = mock_run_external_process.call_args[1] + assert "line_callbacks" in call_kwargs + assert len(call_kwargs["line_callbacks"]) == 1 diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index ca3fd9b78ab..ff58fb13944 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Callable +import io from pathlib import Path import subprocess import sys +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -407,6 +410,178 @@ def test_shlex_quote_edge_cases() -> None: assert util.shlex_quote(" ") == "' '" +def _make_redirect( + line_callbacks: list[Callable[[str], str | None]] | None = None, + filter_lines: list[str] | None = None, +) -> tuple[util.RedirectText, io.StringIO]: + """Create a RedirectText that writes to a StringIO buffer.""" + buf = io.StringIO() + redirect = util.RedirectText( + buf, filter_lines=filter_lines, line_callbacks=line_callbacks + ) + return redirect, buf + + +def test_redirect_text_callback_called_on_matching_line() -> None: + """Test that a line callback is called and its output is written.""" + results: list[str] = [] + + def callback(line: str) -> str | None: + results.append(line) + if "target" in line: + return "CALLBACK OUTPUT\n" + return None + + redirect, buf = _make_redirect(line_callbacks=[callback]) + redirect.write("some target line\n") + + assert "some target line" in buf.getvalue() + assert "CALLBACK OUTPUT" in buf.getvalue() + assert len(results) == 1 + + +def test_redirect_text_callback_not_triggered_on_non_matching_line() -> None: + """Test that callback returns None for non-matching lines.""" + + def callback(line: str) -> str | None: + if "target" in line: + return "FOUND\n" + return None + + redirect, buf = _make_redirect(line_callbacks=[callback]) + redirect.write("no match here\n") + + assert "no match here" in buf.getvalue() + assert "FOUND" not in buf.getvalue() + + +def test_redirect_text_callback_works_without_filter_pattern() -> None: + """Test that callbacks fire even when no filter_lines is set.""" + + def callback(line: str) -> str | None: + if "Crystal" in line: + return "WARNING: mismatch\n" + return None + + redirect, buf = _make_redirect(line_callbacks=[callback]) + redirect.write("Crystal frequency: 26MHz\n") + + assert "Crystal frequency: 26MHz" in buf.getvalue() + assert "WARNING: mismatch" in buf.getvalue() + + +def test_redirect_text_callback_works_with_filter_pattern() -> None: + """Test that callbacks fire alongside filter patterns.""" + + def callback(line: str) -> str | None: + if "important" in line: + return "NOTED\n" + return None + + redirect, buf = _make_redirect( + line_callbacks=[callback], + filter_lines=[r"^skip this.*"], + ) + redirect.write("skip this line\n") + redirect.write("important line\n") + + assert "skip this" not in buf.getvalue() + assert "important line" in buf.getvalue() + assert "NOTED" in buf.getvalue() + + +def test_redirect_text_multiple_callbacks() -> None: + """Test that multiple callbacks are all invoked.""" + + def callback_a(line: str) -> str | None: + if "test" in line: + return "FROM A\n" + return None + + def callback_b(line: str) -> str | None: + if "test" in line: + return "FROM B\n" + return None + + redirect, buf = _make_redirect(line_callbacks=[callback_a, callback_b]) + redirect.write("test line\n") + + output = buf.getvalue() + assert "FROM A" in output + assert "FROM B" in output + + +def test_redirect_text_incomplete_line_buffered() -> None: + """Test that incomplete lines are buffered until newline.""" + results: list[str] = [] + + def callback(line: str) -> str | None: + results.append(line) + return None + + redirect, buf = _make_redirect(line_callbacks=[callback]) + redirect.write("partial") + assert len(results) == 0 + + redirect.write(" line\n") + assert len(results) == 1 + assert results[0] == "partial line" + + +def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> None: + """Test that run_external_command passes line_callbacks to RedirectText.""" + results: list[str] = [] + + def callback(line: str) -> str | None: + results.append(line) + if "hello" in line: + return "CALLBACK FIRED\n" + return None + + def fake_main() -> int: + print("hello world") + return 0 + + rc = util.run_external_command(fake_main, "fake", line_callbacks=[callback]) + + assert rc == 0 + assert len(results) == 1 + assert "hello world" in results[0] + captured = capsys.readouterr() + assert "CALLBACK FIRED" in captured.out + + +def test_run_external_process_line_callbacks() -> None: + """Test that run_external_process passes line_callbacks to RedirectText.""" + results: list[str] = [] + + def callback(line: str) -> str | None: + results.append(line) + if "from subprocess" in line: + return "PROCESS CALLBACK\n" + return None + + with patch("esphome.util.subprocess.run") as mock_run: + + def run_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + # Simulate subprocess writing to the stdout RedirectText + stdout = kwargs.get("stdout") + if stdout is not None and isinstance(stdout, util.RedirectText): + stdout.write("from subprocess\n") + return MagicMock(returncode=0) + + mock_run.side_effect = run_side_effect + + rc = util.run_external_process( + "echo", + "test", + line_callbacks=[callback], + ) + + assert rc == 0 + assert any("from subprocess" in r for r in results) + + def test_get_picotool_path_found(tmp_path: Path) -> None: """Test picotool path derivation from cc_path.""" # Create the expected directory structure From 9513edc46875904c9638133c9feb612de9301c62 Mon Sep 17 00:00:00 2001 From: CFlix <38142312+CFlix@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:17:13 +0100 Subject: [PATCH 16/44] [dew_point] Add dew_point sensor component (#14441) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/dew_point/__init__.py | 1 + esphome/components/dew_point/dew_point.cpp | 82 +++++++++++++++++++ esphome/components/dew_point/dew_point.h | 26 ++++++ esphome/components/dew_point/sensor.py | 46 +++++++++++ tests/components/dew_point/common.yaml | 19 +++++ .../components/dew_point/test.esp32-idf.yaml | 1 + .../dew_point/test.esp8266-ard.yaml | 1 + .../components/dew_point/test.rp2040-ard.yaml | 1 + 9 files changed, 178 insertions(+) create mode 100644 esphome/components/dew_point/__init__.py create mode 100644 esphome/components/dew_point/dew_point.cpp create mode 100644 esphome/components/dew_point/dew_point.h create mode 100644 esphome/components/dew_point/sensor.py create mode 100644 tests/components/dew_point/common.yaml create mode 100644 tests/components/dew_point/test.esp32-idf.yaml create mode 100644 tests/components/dew_point/test.esp8266-ard.yaml create mode 100644 tests/components/dew_point/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 12aff01e730..e72b1647614 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -132,6 +132,7 @@ esphome/components/dashboard_import/* @esphome/core esphome/components/datetime/* @jesserockz @rfdarter esphome/components/debug/* @esphome/core esphome/components/delonghi/* @grob6000 +esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter diff --git a/esphome/components/dew_point/__init__.py b/esphome/components/dew_point/__init__.py new file mode 100644 index 00000000000..3b852436c3c --- /dev/null +++ b/esphome/components/dew_point/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@CFlix"] diff --git a/esphome/components/dew_point/dew_point.cpp b/esphome/components/dew_point/dew_point.cpp new file mode 100644 index 00000000000..04ac305e3d2 --- /dev/null +++ b/esphome/components/dew_point/dew_point.cpp @@ -0,0 +1,82 @@ + +#include "dew_point.h" + +namespace esphome::dew_point { + +static const char *const TAG = "dew_point.sensor"; + +void DewPointComponent::setup() { + // Register callbacks for sensor updates + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->add_on_state_callback([this](float state) { + this->temperature_value_ = state; + this->enable_loop(); + }); + // Get initial value + if (this->temperature_sensor_->has_state()) { + this->temperature_value_ = this->temperature_sensor_->get_state(); + } + } + + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->add_on_state_callback([this](float state) { + this->humidity_value_ = state; + this->enable_loop(); + }); + // Get initial value + if (this->humidity_sensor_->has_state()) { + this->humidity_value_ = this->humidity_sensor_->get_state(); + } + } +} + +void DewPointComponent::dump_config() { + LOG_SENSOR("", "Dew Point", this); + ESP_LOGCONFIG(TAG, + "Sources\n" + " Temperature: '%s'\n" + " Humidity: '%s'", + this->temperature_sensor_->get_name().c_str(), this->humidity_sensor_->get_name().c_str()); +} + +float DewPointComponent::get_setup_priority() const { return setup_priority::DATA; } + +void DewPointComponent::loop() { + // Only run once + this->disable_loop(); + + // Check if we have valid values for both sensors + if (std::isnan(this->temperature_value_) || std::isnan(this->humidity_value_)) { + ESP_LOGW(TAG, "Temperature or humidity value is NaN, skipping calculation"); + this->publish_state(NAN); + return; + } + + // Check for valid humidity range + if (this->humidity_value_ <= 0.0f || this->humidity_value_ > 100.0f) { + ESP_LOGW(TAG, "Humidity value out of range (0-100): %.2f", this->humidity_value_); + this->publish_state(NAN); + return; + } + + // Magnus formula constants + const float a{17.625f}; + const float b{243.04f}; + + // Calculate dew point using Magnus formula + // Td = (b * alpha) / (a - alpha) + // where alpha = ln(RH/100) + (a * T) / (b + T) + + const float alpha{std::log(this->humidity_value_ / 100.0f) + + (a * this->temperature_value_) / (b + this->temperature_value_)}; + + const float dew_point{(b * alpha) / (a - alpha)}; + + // Publish the calculated dew point + this->publish_state(dew_point); + + ESP_LOGD(TAG, "'%s' >> %.1f°C (T: %.1f°C, RH: %.1f%%)", this->get_name().c_str(), dew_point, this->temperature_value_, + this->humidity_value_); +} + +} // namespace esphome::dew_point diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h new file mode 100644 index 00000000000..833c50fba25 --- /dev/null +++ b/esphome/components/dew_point/dew_point.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::dew_point { + +class DewPointComponent : public Component, public sensor::Sensor { + public: + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + + void setup() override; + void dump_config() override; + void loop() override; + + float get_setup_priority() const override; + + protected: + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + float temperature_value_{NAN}; + float humidity_value_{NAN}; +}; + +} // namespace esphome::dew_point diff --git a/esphome/components/dew_point/sensor.py b/esphome/components/dew_point/sensor.py new file mode 100644 index 00000000000..4fee0956021 --- /dev/null +++ b/esphome/components/dew_point/sensor.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) + +DEPENDENCIES = ["sensor"] + +dew_point_ns = cg.esphome_ns.namespace("dew_point") +DewPointComponent = dew_point_ns.class_( + "DewPointComponent", cg.Component, sensor.Sensor +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + DewPointComponent, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:weather-rainy", + ) + .extend( + { + cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), + cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) + cg.add(var.set_temperature_sensor(temperature_sensor)) + + humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) + cg.add(var.set_humidity_sensor(humidity_sensor)) diff --git a/tests/components/dew_point/common.yaml b/tests/components/dew_point/common.yaml new file mode 100644 index 00000000000..527eeb2f84b --- /dev/null +++ b/tests/components/dew_point/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: dew_point + name: Dew Point + temperature: template_temperature + humidity: template_humidity + - platform: template + id: template_humidity + lambda: |- + if (millis() > 10000) { + return 0.6; + } + return 0.0; + - platform: template + id: template_temperature + lambda: |- + if (millis() > 10000) { + return 42.0; + } + return 0.0; diff --git a/tests/components/dew_point/test.esp32-idf.yaml b/tests/components/dew_point/test.esp32-idf.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/dew_point/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/dew_point/test.esp8266-ard.yaml b/tests/components/dew_point/test.esp8266-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/dew_point/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/dew_point/test.rp2040-ard.yaml b/tests/components/dew_point/test.rp2040-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/dew_point/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 30c8c6870383f5c5e6d3f47328b3b685fc8a1518 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 11:22:23 -1000 Subject: [PATCH 17/44] [socket] Fix RP2040 TCP race condition between lwip callbacks and main loop (#14679) --- esphome/components/rp2040/helpers.cpp | 16 +++++- .../components/socket/lwip_raw_tcp_impl.cpp | 52 +++++++++++++++++++ esphome/components/socket/lwip_raw_tcp_impl.h | 6 +++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 30b40a723a3..4191c2164ad 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -7,6 +7,7 @@ #if defined(USE_WIFI) #include +#include // For cyw43_arch_lwip_begin/end (LwIPLock) #endif #include #include @@ -44,9 +45,22 @@ void Mutex::unlock() {} IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); } IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } -// RP2040 doesn't support lwIP core locking, so this is a no-op +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks run from a low-priority user IRQ context, not the +// main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). cyw43_arch_lwip_begin/end acquires the +// async_context recursive mutex to prevent IRQ callbacks from firing during +// critical sections. See esphome#10681. +// +// When CYW43 is not available (non-WiFi RP2040 boards), this is a no-op since +// there's no network stack and no lwip callbacks to race with. +#if defined(USE_WIFI) +LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); } +LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); } +#else LwIPLock::LwIPLock() {} LwIPLock::~LwIPLock() {} +#endif void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) #ifdef USE_WIFI diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 445a57809d2..d7fa6a26945 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -111,6 +111,24 @@ void socket_wake() { } #endif +// ---- LWIP thread safety ---- +// +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority +// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). They can preempt main-loop code at any point. +// +// Without locking, this causes race conditions between recv_fn and read() on the +// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing +// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. +// +// On ESP8266, lwip callbacks run from the SYS context which cooperates with user +// code (CONT context) — they never preempt each other, so no locking is needed. +// +// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +#define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT + static const char *const TAG = "socket.lwip"; // set to 1 to enable verbose lwip logging @@ -123,6 +141,7 @@ static const char *const TAG = "socket.lwip"; // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { + LWIP_LOCK(); if (this->pcb_ != nullptr) { LWIP_LOG("tcp_abort(%p)", this->pcb_); tcp_abort(this->pcb_); @@ -131,6 +150,7 @@ LWIPRawCommon::~LWIPRawCommon() { } int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -196,6 +216,7 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { } int LWIPRawCommon::close() { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -214,6 +235,7 @@ int LWIPRawCommon::close() { } int LWIPRawCommon::shutdown(int how) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -240,6 +262,7 @@ int LWIPRawCommon::shutdown(int how) { } int LWIPRawCommon::getpeername(struct sockaddr *name, socklen_t *addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -252,6 +275,7 @@ int LWIPRawCommon::getpeername(struct sockaddr *name, socklen_t *addrlen) { } int LWIPRawCommon::getsockname(struct sockaddr *name, socklen_t *addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -284,6 +308,7 @@ size_t LWIPRawCommon::getsockname_to(std::span buf) { } int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -318,6 +343,7 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o } int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -388,6 +414,7 @@ int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *n // ---- LWIPRawImpl methods ---- LWIPRawImpl::~LWIPRawImpl() { + LWIP_LOCK(); // Free any received pbufs that LWIP transferred ownership of via recv_fn. // tcp_abort() in the base destructor won't free these since LWIP considers // ownership transferred once the recv callback accepts them. @@ -399,6 +426,7 @@ LWIPRawImpl::~LWIPRawImpl() { } void LWIPRawImpl::init() { + LWIP_LOCK(); LWIP_LOG("init(%p)", this->pcb_); tcp_arg(this->pcb_, this); tcp_recv(this->pcb_, LWIPRawImpl::s_recv_fn); @@ -406,6 +434,9 @@ void LWIPRawImpl::init() { } void LWIPRawImpl::s_err_fn(void *arg, err_t err) { + // Called by lwip core which already holds the async_context lock on RP2040. + // No LWIP_LOCK() needed — acquiring it would be redundant (recursive mutex). + // // "If a connection is aborted because of an error, the application is alerted of this event by // the err callback." // pcb is already freed when this callback is called @@ -422,6 +453,7 @@ err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, er } err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { + // Called by lwip core which already holds the async_context lock on RP2040. LWIP_LOG("recv(pb=%p err=%d)", pb, err); if (err != 0) { // "An error code if there has been an error receiving Only return ERR_ABRT if you have @@ -448,6 +480,7 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -507,6 +540,7 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t ret = 0; for (int i = 0; i < iovcnt; i++) { ssize_t err = this->read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); @@ -525,6 +559,7 @@ ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { } ssize_t LWIPRawImpl::internal_write_(const void *buf, size_t len) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -557,6 +592,11 @@ ssize_t LWIPRawImpl::internal_write_(const void *buf, size_t len) { } int LWIPRawImpl::internal_output_() { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = ECONNRESET; + return -1; + } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); if (err == ERR_ABRT) { @@ -576,6 +616,7 @@ int LWIPRawImpl::internal_output_() { } ssize_t LWIPRawImpl::write(const void *buf, size_t len) { + LWIP_LOCK(); // Hold for write + optional output ssize_t written = this->internal_write_(buf, len); if (written == -1) return -1; @@ -592,6 +633,7 @@ ssize_t LWIPRawImpl::write(const void *buf, size_t len) { } ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t written = 0; for (int i = 0; i < iovcnt; i++) { ssize_t err = this->internal_write_(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); @@ -621,6 +663,7 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { // ---- LWIPRawListenImpl methods ---- LWIPRawListenImpl::~LWIPRawListenImpl() { + LWIP_LOCK(); // Listen PCBs must use tcp_close(), not tcp_abort(). // tcp_abandon() asserts pcb->state != LISTEN and would access // fields that don't exist in the smaller tcp_pcb_listen struct. @@ -632,6 +675,7 @@ LWIPRawListenImpl::~LWIPRawListenImpl() { } void LWIPRawListenImpl::init() { + LWIP_LOCK(); LWIP_LOG("init(%p)", this->pcb_); tcp_arg(this->pcb_, this); tcp_accept(this->pcb_, LWIPRawListenImpl::s_accept_fn); @@ -639,6 +683,7 @@ void LWIPRawListenImpl::init() { } void LWIPRawListenImpl::s_err_fn(void *arg, err_t err) { + // Called by lwip core which already holds the async_context lock on RP2040. auto *arg_this = reinterpret_cast(arg); ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err); arg_this->pcb_ = nullptr; @@ -650,6 +695,7 @@ err_t LWIPRawListenImpl::s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t er } std::unique_ptr LWIPRawListenImpl::accept(struct sockaddr *addr, socklen_t *addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return nullptr; @@ -674,6 +720,7 @@ std::unique_ptr LWIPRawListenImpl::accept(struct sockaddr *addr, so } int LWIPRawListenImpl::listen(int backlog) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -699,6 +746,7 @@ int LWIPRawListenImpl::listen(int backlog) { } err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { + // Called by lwip core which already holds the async_context lock on RP2040. LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -734,6 +782,7 @@ std::unique_ptr socket(int domain, int type, int protocol) { errno = EPROTOTYPE; return nullptr; } + LWIP_LOCK(); auto *pcb = tcp_new(); if (pcb == nullptr) return nullptr; @@ -753,6 +802,7 @@ std::unique_ptr socket_listen(int domain, int type, int protocol) errno = EPROTOTYPE; return nullptr; } + LWIP_LOCK(); auto *pcb = tcp_new(); if (pcb == nullptr) return nullptr; @@ -766,6 +816,8 @@ std::unique_ptr socket_listen_loop_monitored(int domain, int type, return socket_listen(domain, type, protocol); } +#undef LWIP_LOCK + } // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index c171e0537f3..5b2c11cfe2c 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -95,8 +95,13 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ENOSYS; return -1; } + // Intentionally unlocked — this is a polling check called every loop iteration. + // A stale read at worst delays processing by one loop tick; the actual I/O in + // read() holds the lwip lock and re-checks properly. See esphome#10681. bool ready() const { return this->rx_buf_ != nullptr || this->rx_closed_ || this->pcb_ == nullptr; } + // No lock needed — only called during setup before callbacks are registered. + // A stale pcb_ read is benign (returns ECONNRESET, which the caller handles). int setblocking(bool blocking) { if (this->pcb_ == nullptr) { errno = ECONNRESET; @@ -134,6 +139,7 @@ class LWIPRawListenImpl : public LWIPRawCommon { void init(); + // Intentionally unlocked — polling check, see LWIPRawImpl::ready() comment. bool ready() const { return this->accepted_socket_count_ > 0; } std::unique_ptr accept(struct sockaddr *addr, socklen_t *addrlen); From dcbf3c8728a1a0040e0f2899a6bfc899888f0e9e Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Tue, 10 Mar 2026 23:18:35 +0100 Subject: [PATCH 18/44] [esp32] gpio type improvements (#14517) --- esphome/components/esp32/gpio.py | 4 ++-- esphome/components/esp32/gpio_esp32.py | 5 +++-- esphome/components/esp32/gpio_esp32_c2.py | 5 +++-- esphome/components/esp32/gpio_esp32_c3.py | 5 +++-- esphome/components/esp32/gpio_esp32_c5.py | 5 +++-- esphome/components/esp32/gpio_esp32_c6.py | 5 +++-- esphome/components/esp32/gpio_esp32_c61.py | 5 +++-- esphome/components/esp32/gpio_esp32_h2.py | 5 +++-- esphome/components/esp32/gpio_esp32_p4.py | 5 +++-- esphome/components/esp32/gpio_esp32_s2.py | 5 +++-- esphome/components/esp32/gpio_esp32_s3.py | 5 +++-- 11 files changed, 32 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index c0803f40a83..a7180cbcd7e 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -88,8 +88,8 @@ def _translate_pin(value): @dataclass class ESP32ValidationFunctions: - pin_validation: Callable[[Any], Any] - usage_validation: Callable[[Any], Any] + pin_validation: Callable[[int], int] + usage_validation: Callable[[dict[str, Any]], dict[str, Any]] _esp32_validations = { diff --git a/esphome/components/esp32/gpio_esp32.py b/esphome/components/esp32/gpio_esp32.py index 973d2dc0ef3..b3166cf822e 100644 --- a/esphome/components/esp32/gpio_esp32.py +++ b/esphome/components/esp32/gpio_esp32.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import ( @@ -22,7 +23,7 @@ _ESP32_STRAPPING_PINS = {0, 2, 5, 12, 15} _LOGGER = logging.getLogger(__name__) -def esp32_validate_gpio_pin(value): +def esp32_validate_gpio_pin(value: int) -> int: if value < 0 or value > 39: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") if value in _ESP_SDIO_PINS: @@ -41,7 +42,7 @@ def esp32_validate_gpio_pin(value): return value -def esp32_validate_supports(value): +def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c2.py b/esphome/components/esp32/gpio_esp32_c2.py index 32a24050ca7..2d6e3a4a4ed 100644 --- a/esphome/components/esp32/gpio_esp32_c2.py +++ b/esphome/components/esp32/gpio_esp32_c2.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -9,14 +10,14 @@ _ESP32C2_STRAPPING_PINS = {8, 9} _LOGGER = logging.getLogger(__name__) -def esp32_c2_validate_gpio_pin(value): +def esp32_c2_validate_gpio_pin(value: int) -> int: if value < 0 or value > 20: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-20)") return value -def esp32_c2_validate_supports(value): +def esp32_c2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c3.py b/esphome/components/esp32/gpio_esp32_c3.py index c1427cc02aa..93e0b970934 100644 --- a/esphome/components/esp32/gpio_esp32_c3.py +++ b/esphome/components/esp32/gpio_esp32_c3.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -18,7 +19,7 @@ _ESP32C3_STRAPPING_PINS = {2, 8, 9} _LOGGER = logging.getLogger(__name__) -def esp32_c3_validate_gpio_pin(value): +def esp32_c3_validate_gpio_pin(value: int) -> int: if value < 0 or value > 21: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-21)") if value in _ESP32C3_SPI_PSRAM_PINS: @@ -29,7 +30,7 @@ def esp32_c3_validate_gpio_pin(value): return value -def esp32_c3_validate_supports(value): +def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index fa2ce1a689c..639ed64c9ea 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA @@ -22,7 +23,7 @@ _ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} _LOGGER = logging.getLogger(__name__) -def esp32_c5_validate_gpio_pin(value): +def esp32_c5_validate_gpio_pin(value: int) -> int: if value < 0 or value > 28: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-28)") if value in _ESP32C5_SPI_PSRAM_PINS: @@ -33,7 +34,7 @@ def esp32_c5_validate_gpio_pin(value): return value -def esp32_c5_validate_supports(value): +def esp32_c5_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index 5d679dede25..cfd3bca8334 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA @@ -22,7 +23,7 @@ _ESP32C6_STRAPPING_PINS = {8, 9, 15} _LOGGER = logging.getLogger(__name__) -def esp32_c6_validate_gpio_pin(value): +def esp32_c6_validate_gpio_pin(value: int) -> int: if value < 0 or value > 23: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-23)") if value in _ESP32C6_SPI_PSRAM_PINS: @@ -33,7 +34,7 @@ def esp32_c6_validate_gpio_pin(value): return value -def esp32_c6_validate_supports(value): +def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py index 77be42db3e7..2f3abe6a0f9 100644 --- a/esphome/components/esp32/gpio_esp32_c61.py +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -20,7 +21,7 @@ _ESP32C61_STRAPPING_PINS = {8, 9} _LOGGER = logging.getLogger(__name__) -def esp32_c61_validate_gpio_pin(value): +def esp32_c61_validate_gpio_pin(value: int) -> int: if value < 0 or value > 29: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-29)") if value in _ESP32C61_SPI_PSRAM_PINS: @@ -31,7 +32,7 @@ def esp32_c61_validate_gpio_pin(value): return value -def esp32_c61_validate_supports(value): +def esp32_c61_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index f37297764b8..5e7a6158f9b 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -13,7 +14,7 @@ _ESP32H2_STRAPPING_PINS = {2, 3, 8, 9, 25} _LOGGER = logging.getLogger(__name__) -def esp32_h2_validate_gpio_pin(value): +def esp32_h2_validate_gpio_pin(value: int) -> int: if value < 0 or value > 27: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-27)") if value in _ESP32H2_SPI_FLASH_PINS: @@ -33,7 +34,7 @@ def esp32_h2_validate_gpio_pin(value): return value -def esp32_h2_validate_supports(value): +def esp32_h2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 2726c5932fa..865db926524 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA @@ -14,7 +15,7 @@ _ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38} _LOGGER = logging.getLogger(__name__) -def esp32_p4_validate_gpio_pin(value): +def esp32_p4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") if value in _ESP32P4_USB_JTAG_PINS: @@ -27,7 +28,7 @@ def esp32_p4_validate_gpio_pin(value): return value -def esp32_p4_validate_supports(value): +def esp32_p4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_s2.py b/esphome/components/esp32/gpio_esp32_s2.py index 331aeb9d94b..4978f48a1c2 100644 --- a/esphome/components/esp32/gpio_esp32_s2.py +++ b/esphome/components/esp32/gpio_esp32_s2.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import ( @@ -26,7 +27,7 @@ _ESP32S2_STRAPPING_PINS = {0, 45, 46} _LOGGER = logging.getLogger(__name__) -def esp32_s2_validate_gpio_pin(value): +def esp32_s2_validate_gpio_pin(value: int) -> int: if value < 0 or value > 46: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-46)") @@ -43,7 +44,7 @@ def esp32_s2_validate_gpio_pin(value): return value -def esp32_s2_validate_supports(value): +def esp32_s2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index aea378f499d..cb0eb8178c3 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -27,7 +28,7 @@ _ESP_32S3_STRAPPING_PINS = {0, 3, 45, 46} _LOGGER = logging.getLogger(__name__) -def esp32_s3_validate_gpio_pin(value): +def esp32_s3_validate_gpio_pin(value: int) -> int: if value < 0 or value > 48: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-48)") @@ -49,7 +50,7 @@ def esp32_s3_validate_gpio_pin(value): return value -def esp32_s3_validate_supports(value): +def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] From b84d773becad306de294beadb0a3efa73c9a854d Mon Sep 17 00:00:00 2001 From: CFlix <38142312+CFlix@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:24:46 +0100 Subject: [PATCH 19/44] [bme280] Change communication error message to include "no response" hint. (#14686) --- esphome/components/bme280_base/bme280_base.cpp | 2 +- esphome/components/bmp280_base/bmp280_base.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index addbfe618d8..f31940df106 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -7,7 +7,7 @@ #include #include -#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID" +#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" namespace esphome { namespace bme280_base { diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index de685e7c278..603966a2b52 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -2,7 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID" +#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" namespace esphome { namespace bmp280_base { From 794098de99f083105440f8a36f2f070d1e6f9a04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 16:40:45 -1000 Subject: [PATCH 20/44] [rp2040] Add HardFault crash handler with backtrace (#14685) --- esphome/components/logger/logger_rp2040.cpp | 2 + esphome/components/rp2040/__init__.py | 47 ++++ esphome/components/rp2040/core.cpp | 2 + esphome/components/rp2040/crash_handler.cpp | 227 ++++++++++++++++++++ esphome/components/rp2040/crash_handler.h | 17 ++ 5 files changed, 295 insertions(+) create mode 100644 esphome/components/rp2040/crash_handler.cpp create mode 100644 esphome/components/rp2040/crash_handler.h diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index 1f435031f61..f76b823a8f7 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,5 +1,6 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/components/rp2040/crash_handler.h" #include "esphome/core/log.h" namespace esphome::logger { @@ -25,6 +26,7 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); + rp2040::crash_handler_log(); } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 359337adfb9..b15811241ca 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,6 +1,8 @@ import logging from pathlib import Path +import re from string import ascii_letters, digits +import subprocess import esphome.codegen as cg import esphome.config_validation as cv @@ -264,3 +266,48 @@ def copy_files(): path = CORE.relative_src_path("esphome.h") content = read_file(path).rstrip("\n") write_file_if_changed(path, content + '\n#include "pio_includes.h"\n') + + +# RP2040 crash handler stacktrace decoding +# Matches output from esphome/components/rp2040/crash_handler.cpp +_CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT") +_CRASH_ADDR_RE = re.compile( + r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" +) + + +def _addr2line(tool: str, elf: Path, addr: str) -> str: + try: + result = subprocess.run( + [tool, "-pfiaC", "-e", str(elf), addr], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except (OSError, subprocess.CalledProcessError): + return f"{addr} (decode failed)" + + +def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: + """Decode RP2040 crash handler output using addr2line.""" + if _CRASH_RE.search(line): + _LOGGER.error("RP2040 crash detected - decoding addresses") + return True + + if backtrace_state: + if match := _CRASH_ADDR_RE.search(line): + from esphome.platformio_api import get_idedata + + idedata = get_idedata(config) + if idedata.addr2line_path: + elf = idedata.firmware_elf_path + if elf.exists(): + decoded = _addr2line(idedata.addr2line_path, elf, match.group(1)) + _LOGGER.error(" %s => %s", match.group(1), decoded) + + # Stop backtrace state after addr2line hint (last line of crash dump) + if "addr2line" in line: + return False + + return backtrace_state diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 63b154d80de..5e5a96c78b1 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_RP2040 #include "core.h" +#include "crash_handler.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -24,6 +25,7 @@ void arch_restart() { } void arch_init() { + rp2040::crash_handler_read_and_clear(); #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp new file mode 100644 index 00000000000..6ab46da4449 --- /dev/null +++ b/esphome/components/rp2040/crash_handler.cpp @@ -0,0 +1,227 @@ +#ifdef USE_RP2040 + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +// Cortex-M0+ exception frame offsets (words) +// When a fault occurs, the CPU pushes: R0, R1, R2, R3, R12, LR, PC, xPSR +static constexpr uint32_t EF_LR = 5; +static constexpr uint32_t EF_PC = 6; + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; + +// We only have 8 scratch registers (32 bytes) that survive watchdog reboot. +// Use them for the most important data, then scan the stack for code addresses. +// +// Scratch register layout: +// [0] = magic (CRASH_MAGIC) +// [1] = PC (program counter at fault) +// [2] = LR (link register from exception frame) +// [3] = SP (stack pointer at fault) +// [4..7] = up to 4 additional code addresses found by scanning the stack +// (return addresses from callers, giving a deeper backtrace) + +// Flash is mapped at XIP_BASE (0x10000000). We use a conservative upper bound +// to keep false positives low during stack scanning. Wider ranges would match +// more stale data on the stack that happens to look like code addresses. +#if defined(PICO_RP2350) +static constexpr uint32_t FLASH_SCAN_END = XIP_BASE + 0x400000; // 4MB — RP2350 typical max +#else +static constexpr uint32_t FLASH_SCAN_END = XIP_BASE + 0x200000; // 2MB — RP2040 typical max +#endif + +static inline bool is_code_addr(uint32_t val) { + uint32_t cleared = val & ~1u; // Clear Thumb bit + return cleared >= XIP_BASE && cleared < FLASH_SCAN_END; +} + +static constexpr size_t MAX_BACKTRACE = 4; + +namespace esphome::rp2040 { + +static const char *const TAG = "rp2040.crash"; + +// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). +// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +static struct { + bool valid; + uint32_t pc; + uint32_t lr; + uint32_t sp; + uint32_t backtrace[MAX_BACKTRACE]; + uint8_t backtrace_count; +} __attribute__((section(".noinit"))) s_crash_data; + +void crash_handler_read_and_clear() { + s_crash_data.valid = false; + if (watchdog_hw->scratch[0] == CRASH_MAGIC) { + s_crash_data.valid = true; + s_crash_data.pc = watchdog_hw->scratch[1]; + s_crash_data.lr = watchdog_hw->scratch[2]; + s_crash_data.sp = watchdog_hw->scratch[3]; + s_crash_data.backtrace_count = 0; + for (size_t i = 0; i < MAX_BACKTRACE; i++) { + uint32_t addr = watchdog_hw->scratch[4 + i]; + if (addr == 0) + break; + s_crash_data.backtrace[i] = addr; + s_crash_data.backtrace_count++; + } + } + // Clear scratch registers regardless + for (int i = 0; i < 8; i++) { + watchdog_hw->scratch[i] = 0; + } +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console (miniterm), making it possible to see partial output if +// the device crashes again during boot, and allowing the CLI's process_stacktrace +// to match and decode each address individually. +void crash_handler_log() { + if (!s_crash_data.valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_crash_data.pc); + ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_crash_data.lr); + ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_crash_data.sp); + for (uint8_t i = 0; i < s_crash_data.backtrace_count; i++) { + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", i, s_crash_data.backtrace[i]); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[160]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32, + s_crash_data.pc, s_crash_data.lr); + for (uint8_t i = 0; i < s_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_crash_data.backtrace[i]); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::rp2040 + +// --- HardFault handler --- +// Overrides the weak isr_hardfault from arduino-pico's crt0.S. +// On Cortex-M0+, the CPU pushes {R0,R1,R2,R3,R12,LR,PC,xPSR} onto the +// active stack (MSP or PSP). We determine which stack was active, +// extract key registers, store them in watchdog scratch registers +// (which survive watchdog reboot), then trigger a reboot. + +// Check if a pointer falls within SRAM (valid for stack access). +// SRAM_BASE and SRAM_END are chip-specific SDK defines: +// RP2040: 0x20000000 - 0x20042000 (264KB) +// RP2350: 0x20000000 - 0x20082000 (520KB) +static inline bool is_valid_sram_ptr(const uint32_t *ptr) { + auto addr = reinterpret_cast(ptr); + // Exception frame is 8 words (32 bytes), so frame+7 must also be in SRAM. + // Check alignment (must be word-aligned) and that the full frame fits. + return (addr % 4 == 0) && addr >= SRAM_BASE && (addr + 32) <= SRAM_END; +} + +// C handler called from the asm wrapper with the exception frame pointer. +static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame, uint32_t /*exc_return*/) { + // watchdog_reboot() overwrites scratch[4]-[7], so we must call it first + // then write ALL our data after. The 10ms timeout gives us plenty of time. + watchdog_reboot(0, 0, 10); + + // Validate frame pointer before dereferencing. If the HardFault was caused + // by a stacking error or corrupted SP, frame may be invalid. Write a minimal + // crash marker so we at least know a crash occurred. + if (!is_valid_sram_ptr(frame)) { + watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[1] = 0; // PC unknown + watchdog_hw->scratch[2] = 0; // LR unknown + watchdog_hw->scratch[3] = reinterpret_cast(frame); // Record the bad SP for diagnosis + for (uint32_t i = 0; i < MAX_BACKTRACE; i++) { + watchdog_hw->scratch[4 + i] = 0; + } + while (true) { + __asm volatile("nop"); + } + } + + // Pre-fault SP: the exception frame is 8 words pushed onto the stack, + // so the SP before the fault was frame + 8 words. If xPSR bit 9 is set, + // the hardware pushed an extra alignment word to maintain 8-byte stack + // alignment (ARMv6-M/ARMv7-M spec), so add 1 more word. + static constexpr uint32_t EF_XPSR = 7; + uint32_t extra_align = (frame[EF_XPSR] & (1u << 9)) ? 1 : 0; + uint32_t *post_frame = frame + 8 + extra_align; + uint32_t pre_fault_sp = reinterpret_cast(post_frame); + + // Write key registers + watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[1] = frame[EF_PC]; + watchdog_hw->scratch[2] = frame[EF_LR]; + watchdog_hw->scratch[3] = pre_fault_sp; + + // Scan stack for code addresses to build a deeper backtrace. + // The exception frame is 8 words (32 bytes) at 'frame', plus an optional + // alignment word. Walk up to 64 words looking for return addresses. + uint32_t *scan_start = post_frame; + // SRAM_END is chip-specific: 0x20042000 (RP2040) or 0x20082000 (RP2350) + uint32_t *stack_top = reinterpret_cast(SRAM_END); + // Scan up to 64 words (256 bytes) — covers typical nested call frames + // without scanning too much stale stack data that could produce false positives. + uint32_t bt_count = 0; + + for (uint32_t *p = scan_start; p < stack_top && p < scan_start + 64 && bt_count < MAX_BACKTRACE; p++) { + uint32_t val = *p; + // Check if this looks like a code address in flash + // Skip if it's the same as PC or LR we already saved + if (is_code_addr(val) && val != frame[EF_PC] && val != frame[EF_LR]) { + watchdog_hw->scratch[4 + bt_count] = val; + bt_count++; + } + } + // Zero remaining slots + for (uint32_t i = bt_count; i < MAX_BACKTRACE; i++) { + watchdog_hw->scratch[4 + i] = 0; + } + + while (true) { + __asm volatile("nop"); + } +} + +// Naked asm wrapper - Cortex-M0+ compatible (no ITE/conditional execution). +// Determines active stack pointer and branches to C handler. +// Uses literal pool (.word) for addresses since M0+ has limited immediate encoding. +// +// Based on the standard Cortex-M0+ HardFault handler pattern described in: +// - ARM Application Note AN209: "Using Cortex-M3/M4/M7 Fault Exceptions" +// (adapted for M0+ which lacks conditional execution instructions) +// - Memfault: "How to debug a HardFault on an ARM Cortex-M MCU" +// https://interrupt.memfault.com/blog/cortex-m-hardfault-debug +// - Raspberry Pi Forums: "Cortex-M0+ Hard Fault handler porting" +// https://www.eevblog.com/forum/microcontrollers/cortex-m0-hard-fault-handler-porting/ +// +// The key M0+ adaptation: replaces ITE/MRSEQ/MRSNE (Cortex-M3+) with +// MOVS+TST+BEQ branch sequence, and uses a literal pool for the C handler address. +extern "C" void __attribute__((naked, used)) isr_hardfault() { + __asm volatile("movs r0, #4 \n" // Prepare bit 2 mask + "mov r1, lr \n" // r1 = EXC_RETURN + "tst r1, r0 \n" // Test bit 2 + "beq 1f \n" // If 0, was using MSP + "mrs r0, psp \n" // Bit 2 set = PSP was active + "b 2f \n" + "1: \n" + "mrs r0, msp \n" // Bit 2 clear = MSP was active + "2: \n" + // r0 = exception frame pointer, r1 = EXC_RETURN (still in r1) + "ldr r2, 3f \n" // Load C handler address from literal pool + "bx r2 \n" // Branch to handler (r0=frame, r1=exc_return) + ".align 2 \n" + "3: .word %c0 \n" // Literal pool: address of C handler + : + : "i"(hard_fault_handler_c)); +} + +#endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h new file mode 100644 index 00000000000..f10db47c234 --- /dev/null +++ b/esphome/components/rp2040/crash_handler.h @@ -0,0 +1,17 @@ +#pragma once + +#ifdef USE_RP2040 + +#include + +namespace esphome::rp2040 { + +/// Read crash data from watchdog scratch registers and clear them. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +} // namespace esphome::rp2040 + +#endif // USE_RP2040 From 6561c9bc95500915584afbe13a8cf3da6f3da051 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 10 Mar 2026 22:32:29 -0500 Subject: [PATCH 21/44] [core] Fix waiting for port indefinitely (#14688) --- esphome/__main__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3f0da85a694..58b995e8df4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -919,9 +919,11 @@ def _wait_for_serial_port( """ def _port_found() -> bool: - ports = get_serial_ports() if port is not None: - return any(p.path == port for p in ports) + if os.name == "posix": + return os.path.exists(port) + return any(p.path == port for p in get_serial_ports()) + ports = get_serial_ports() if known_ports is not None: return any(p.path not in known_ports for p in ports) return bool(ports) From d0f37ae69460ac90e2f95a39216ec09090373df6 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 10 Mar 2026 23:31:27 -0500 Subject: [PATCH 22/44] [logger] Fix UART selection not applied before `pre_setup()` (#14690) --- esphome/components/logger/__init__.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index e370f4215d4..675f9a2ca4b 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -337,6 +337,16 @@ async def to_code(config): ) if CORE.is_esp32: cg.add(log.create_pthread_key()) + # set_uart_selection() must be called before pre_setup() because + # pre_setup() switches on uart_ to decide which hardware to initialize + # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the + # default UART_SELECTION_UART0 and the wrong hardware gets initialized. + if CONF_HARDWARE_UART in config: + cg.add( + log.set_uart_selection( + HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] + ) + ) # pre_setup() must be called before init_log_buffer() because # init_log_buffer() calls disable_loop() which may log at VV level, # and global_logger must be set before any logging occurs. @@ -354,12 +364,6 @@ async def to_code(config): cg.add(log.init_log_buffer(64)) # Fixed 64 slots for host cg.add(log.set_log_level(initial_level)) - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) # Enable runtime tag levels if logs are configured or explicitly enabled logs_config = config[CONF_LOGS] From 4df3d3554e5767edf6469977972409d3b882cd2e Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Tue, 10 Mar 2026 23:44:05 -0600 Subject: [PATCH 23/44] Enable the address and behavior sanitizers for C++ component unit tests (#13490) --- script/cpp_unit_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c9174584722..c6cfd8270fa 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -79,6 +79,10 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: "-Og", # optimize for debug "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing "-DESPHOME_DEBUG", # enable debug assertions + # Enable the address and undefined behavior sanitizers + "-fsanitize=address", + "-fsanitize=undefined", + "-fno-omit-frame-pointer", ], "debug_build_flags": [ # only for debug builds "-g3", # max debug info From e8e700a683f2ad210892393e6f67260855e493d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 20:51:54 -1000 Subject: [PATCH 24/44] [socket] Fix RP2040 heap corruption from malloc in lwip accept callback (#14687) --- .../components/socket/lwip_raw_tcp_impl.cpp | 142 ++++++++++++++---- esphome/components/socket/lwip_raw_tcp_impl.h | 41 ++--- 2 files changed, 138 insertions(+), 45 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index d7fa6a26945..fd1b8a95542 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -425,20 +425,24 @@ LWIPRawImpl::~LWIPRawImpl() { // Base class destructor handles pcb_ cleanup via tcp_abort } -void LWIPRawImpl::init() { +void LWIPRawImpl::init(struct pbuf *initial_rx, bool initial_rx_closed) { LWIP_LOCK(); LWIP_LOG("init(%p)", this->pcb_); tcp_arg(this->pcb_, this); tcp_recv(this->pcb_, LWIPRawImpl::s_recv_fn); tcp_err(this->pcb_, LWIPRawImpl::s_err_fn); + if (initial_rx != nullptr) { + this->rx_buf_ = initial_rx; + this->rx_buf_offset_ = 0; + } + this->rx_closed_ = initial_rx_closed; } void LWIPRawImpl::s_err_fn(void *arg, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. - // No LWIP_LOCK() needed — acquiring it would be redundant (recursive mutex). + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // No LWIP_LOCK() needed — lwip core already holds the async_context lock. // - // "If a connection is aborted because of an error, the application is alerted of this event by - // the err callback." // pcb is already freed when this callback is called // ERR_RST: connection was reset by remote host // ERR_ABRT: aborted through tcp_abort or TCP timer @@ -453,11 +457,15 @@ err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, er } err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). LWIP_LOG("recv(pb=%p err=%d)", pb, err); if (err != 0) { // "An error code if there has been an error receiving Only return ERR_ABRT if you have // called tcp_abort from within the callback function!" + if (pb != nullptr) { + pbuf_free(pb); + } this->rx_closed_ = true; return ERR_OK; } @@ -664,6 +672,22 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { LWIPRawListenImpl::~LWIPRawListenImpl() { LWIP_LOCK(); + // Abort any queued PCBs that were never accepted by the main loop. + // Clear the error callback first — tcp_abort triggers it, and we don't + // want s_queued_err_fn writing to slots during destruction. + for (uint8_t i = 0; i < this->accepted_socket_count_; i++) { + auto &entry = this->accepted_pcbs_[i]; + if (entry.pcb != nullptr) { + tcp_err(entry.pcb, nullptr); + tcp_abort(entry.pcb); + entry.pcb = nullptr; + } + if (entry.rx_buf != nullptr) { + pbuf_free(entry.rx_buf); + entry.rx_buf = nullptr; + } + } + this->accepted_socket_count_ = 0; // Listen PCBs must use tcp_close(), not tcp_abort(). // tcp_abandon() asserts pcb->state != LISTEN and would access // fields that don't exist in the smaller tcp_pcb_listen struct. @@ -683,12 +707,49 @@ void LWIPRawListenImpl::init() { } void LWIPRawListenImpl::s_err_fn(void *arg, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). auto *arg_this = reinterpret_cast(arg); ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err); arg_this->pcb_ = nullptr; } +void LWIPRawListenImpl::s_queued_err_fn(void *arg, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // Called when a queued (not yet accepted) PCB errors — e.g., remote sent RST. + // The PCB is already freed by lwip. Null our pointer so accept() skips it. + (void) err; + auto *entry = reinterpret_cast(arg); + entry->pcb = nullptr; + // Don't free rx_buf here — accept() will clean it up when it sees pcb==nullptr +} + +err_t LWIPRawListenImpl::s_queued_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // Temporary recv callback for PCBs queued between accept_fn_ and accept(). + // Without this, lwip's default tcp_recv_null handler would ACK and drop the data, + // causing the API handshake to silently fail (client sends Hello, server never sees it). + (void) pcb; + auto *entry = reinterpret_cast(arg); + if (pb == nullptr || err != ERR_OK) { + // Remote closed or error + if (pb != nullptr) { + pbuf_free(pb); + } + entry->rx_closed = true; + return ERR_OK; + } + // Buffer the data — tcp_recved() is deferred to read() after accept() creates the socket. + if (entry->rx_buf == nullptr) { + entry->rx_buf = pb; + } else { + pbuf_cat(entry->rx_buf, pb); + } + return ERR_OK; +} + err_t LWIPRawListenImpl::s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { auto *arg_this = reinterpret_cast(arg); return arg_this->accept_fn_(newpcb, err); @@ -700,23 +761,40 @@ std::unique_ptr LWIPRawListenImpl::accept(struct sockaddr *addr, so errno = EBADF; return nullptr; } - if (this->accepted_socket_count_ == 0) { - errno = EWOULDBLOCK; - return nullptr; + // Dequeue front entry, skipping any null entries (PCBs freed by lwip while queued). + // The error callback nulled their pcb pointers; clean up buffered data and discard. + while (this->accepted_socket_count_ > 0) { + QueuedPcb entry = this->accepted_pcbs_[0]; + // Shift remaining entries forward, updating tcp_arg pointers as we go. + // Safe because we hold LWIP_LOCK, so err/recv callbacks can't fire during the update. + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_pcbs_[i - 1] = this->accepted_pcbs_[i]; + if (this->accepted_pcbs_[i - 1].pcb != nullptr) { + tcp_arg(this->accepted_pcbs_[i - 1].pcb, &this->accepted_pcbs_[i - 1]); + } + } + this->accepted_pcbs_[this->accepted_socket_count_ - 1] = {}; + this->accepted_socket_count_--; + if (entry.pcb == nullptr) { + // PCB was freed by lwip (RST/timeout) while queued — discard and try next + if (entry.rx_buf != nullptr) { + pbuf_free(entry.rx_buf); + } + continue; + } + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); + // Create socket wrapper on the main loop (not in accept callback) to avoid + // heap allocation in IRQ context on RP2040. Transfer any data received while queued. + auto sock = make_unique(this->family_, entry.pcb); + sock->init(entry.rx_buf, entry.rx_closed); + if (addr != nullptr) { + sock->getpeername(addr, addrlen); + } + LWIP_LOG("accept(%p)", sock.get()); + return sock; } - // Take from front for FIFO ordering - std::unique_ptr sock = std::move(this->accepted_sockets_[0]); - // Shift remaining sockets forward - for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { - this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); - } - this->accepted_socket_count_--; - LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); - if (addr != nullptr) { - sock->getpeername(addr, addrlen); - } - LWIP_LOG("accept(%p)", sock.get()); - return sock; + errno = EWOULDBLOCK; + return nullptr; } int LWIPRawListenImpl::listen(int backlog) { @@ -746,7 +824,8 @@ int LWIPRawListenImpl::listen(int backlog) { } err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { - // Called by lwip core which already holds the async_context lock on RP2040. + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -763,9 +842,18 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Must return ERR_ABRT since we called tcp_abort() return ERR_ABRT; } - auto sock = make_unique(this->family_, newpcb); - sock->init(); - this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + // Store the raw PCB — LWIPRawImpl creation is deferred to the main-loop accept(). + // This avoids heap allocation in this callback, which is unsafe from IRQ context on RP2040. + uint8_t idx = this->accepted_socket_count_++; + this->accepted_pcbs_[idx] = {newpcb, nullptr, false}; + // Register temporary callbacks so that while the PCB is queued: + // - err: nulls our pointer if the connection errors (RST, timeout) + // - recv: buffers any data that arrives before accept() creates the LWIPRawImpl + // (without this, lwip's default tcp_recv_null would ACK and drop the data) + // tcp_arg points to our queue entry; accept() updates these pointers after shifting. + tcp_arg(newpcb, &this->accepted_pcbs_[idx]); + tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); + tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); #if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can accept the new connection. diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 5b2c11cfe2c..95931afcf3f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -66,7 +66,7 @@ class LWIPRawImpl : public LWIPRawCommon { using LWIPRawCommon::LWIPRawCommon; ~LWIPRawImpl(); - void init(); + void init(struct pbuf *initial_rx = nullptr, bool initial_rx_closed = false); // Non-listening sockets return error std::unique_ptr accept(struct sockaddr *, socklen_t *) { @@ -182,23 +182,28 @@ class LWIPRawListenImpl : public LWIPRawCommon { err_t accept_fn_(struct tcp_pcb *newpcb, err_t err); static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err); - // Accept queue - holds incoming connections briefly until the event loop calls accept() - // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop - // 3 slots is plenty since connections are pulled out quickly by the event loop - // - // Memory analysis: std::array<3> vs original std::queue implementation: - // - std::queue uses std::deque internally which on 32-bit systems needs: - // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations - // Total: ~56+ bytes minimum, plus heap fragmentation - // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) - // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations - // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) - // - // By using a separate listening socket class, regular connected sockets save - // 16 bytes (12 bytes array + 1 byte count + 3 bytes padding) of memory overhead on 32-bit systems - static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; - std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; - uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue + // Temporary callbacks for queued PCBs (between accept_fn_ and accept()) + static void s_queued_err_fn(void *arg, err_t err); + static err_t s_queued_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); + + // Accept queue entry — stores a raw tcp_pcb and any data received while queued. + // lwip's default tcp_recv_null handler drops data and ACKs it, so we must register + // a temporary recv callback to buffer any data that arrives between accept_fn_ + // (which stores the PCB) and accept() (which creates the LWIPRawImpl). + struct QueuedPcb { + struct tcp_pcb *pcb{nullptr}; + struct pbuf *rx_buf{nullptr}; // Data received while queued (before accept() picks it up) + bool rx_closed{false}; // Remote sent FIN while queued + }; + + // Accept queue — stores raw tcp_pcb entries instead of heap-allocated LWIPRawImpl objects. + // LWIPRawImpl creation is deferred to the main-loop accept() call. This avoids: + // - Heap allocation in the accept callback (unsafe from IRQ context on RP2040) + // - Dangling LWIPRawImpl if the connection errors before accept() picks it up + // 2 slots is plenty since the main loop drains the queue every iteration. + static constexpr size_t MAX_ACCEPTED_SOCKETS = 2; + std::array accepted_pcbs_{}; + uint8_t accepted_socket_count_ = 0; // Number of entries currently in queue }; } // namespace esphome::socket From 236f6b1935aec81d6350213e3049c6b0f4fb0794 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Mar 2026 07:52:43 +0100 Subject: [PATCH 25/44] [micronova] Add command queue (#12268) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: edenhaus Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/micronova/__init__.py | 54 +++- .../components/micronova/button/__init__.py | 2 + .../micronova/button/micronova_button.cpp | 10 +- .../micronova/button/micronova_button.h | 11 +- esphome/components/micronova/micronova.cpp | 256 +++++++++++------- esphome/components/micronova/micronova.h | 93 ++++--- .../components/micronova/number/__init__.py | 7 +- .../micronova/number/micronova_number.cpp | 10 +- .../micronova/number/micronova_number.h | 9 +- .../micronova/sensor/micronova_sensor.cpp | 7 + .../micronova/sensor/micronova_sensor.h | 8 +- .../components/micronova/switch/__init__.py | 2 + .../micronova/switch/micronova_switch.cpp | 21 +- .../micronova/switch/micronova_switch.h | 8 +- .../text_sensor/micronova_text_sensor.cpp | 7 + .../text_sensor/micronova_text_sensor.h | 8 +- esphome/core/defines.h | 2 + esphome/core/helpers.h | 75 +++++ 18 files changed, 399 insertions(+), 191 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index d6ef93cf301..b4623522292 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -1,14 +1,34 @@ +from dataclasses import dataclass + from esphome import pins import esphome.codegen as cg from esphome.components import uart 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 CODEOWNERS = ["@jorre05", "@edenhaus"] DEPENDENCIES = ["uart"] DOMAIN = "micronova" + + +@dataclass +class MicronovaData: + """Track micronova component state during code generation.""" + + listener_count: int = 0 + has_writer: bool = False + + +def _get_data() -> MicronovaData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = MicronovaData() + return CORE.data[DOMAIN] + + CONF_MICRONOVA_ID = f"{DOMAIN}_id" CONF_ENABLE_RX_PIN = "enable_rx_pin" CONF_MEMORY_LOCATION = "memory_location" @@ -66,16 +86,42 @@ def MICRONOVA_ADDRESS_SCHEMA( return schema +def register_micronova_writer() -> None: + """Register a component that can write to the stove (button, switch, number).""" + _get_data().has_writer = True + + async def to_code_micronova_listener(mv, var, config): + _get_data().listener_count += 1 await cg.register_component(var, config) - cg.add(mv.register_micronova_listener(var)) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) + # Register listener as last step as we need all properties set before registering + cg.add(mv.register_micronova_listener(var)) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) + var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) await uart.register_uart_device(var, config) - enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - cg.add(var.set_enable_rx_pin(enable_rx_pin)) + CORE.add_job(_final_step) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _final_step() -> None: + """Add defines for listener and writer counts after all are registered.""" + data = _get_data() + if data.listener_count == 0 and not data.has_writer: + raise cv.Invalid( + "No micronova entities configured. Add at least one micronova entity." + ) + if data.listener_count > 255: + raise cv.Invalid( + f"Too many micronova reading entities ({data.listener_count}). Maximum is 255." + ) + if data.listener_count > 0: + cg.add_define("MICRONOVA_LISTENER_COUNT", data.listener_count) + + if data.has_writer: + cg.add_define("USE_MICRONOVA_WRITER") diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 6adf8d96fec..1ef359ea6cf 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -9,6 +9,7 @@ from .. import ( MICRONOVA_ADDRESS_SCHEMA, MicroNova, micronova_ns, + register_micronova_writer, ) MicroNovaButton = micronova_ns.class_("MicroNovaButton", button.Button, cg.Component) @@ -36,6 +37,7 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): + register_micronova_writer() bt = await button.new_button(custom_button_config, mv) cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 3f49d4b5b3b..13c0e1f1176 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -2,9 +2,15 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.button"; + +void MicroNovaButton::dump_config() { + LOG_BUTTON("", "Micronova button", this); + this->dump_base_config(); +} + void MicroNovaButton::press_action() { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); - this->micronova_->request_update_listeners(); + this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 951ae8bba38..0258dbb53c2 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,19 +6,18 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { +class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { public: - MicroNovaButton(MicroNova *m) : MicroNovaButtonListener(m) {} - void dump_config() override { - LOG_BUTTON("", "Micronova button", this); - this->dump_base_config(); - } + MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} + void dump_config() override; void set_memory_data(uint8_t f) { this->memory_data_ = f; } uint8_t get_memory_data() { return this->memory_data_; } protected: void press_action() override; + + uint8_t memory_data_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 22daef4fe6c..c2e3ef86405 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,8 +3,12 @@ namespace esphome::micronova { -static const int STOVE_REPLY_DELAY = 60; -static const uint8_t WRITE_BIT = 1 << 7; // 0x80 +static const char *const TAG = "micronova"; +static constexpr uint8_t STOVE_REPLY_SIZE = 2; +static constexpr uint32_t STOVE_REPLY_TIMEOUT = 200; // ms +static constexpr uint8_t WRITE_BIT = 1 << 7; // 0x80 + +bool MicroNovaCommand::is_write() const { return this->memory_location & WRITE_BIT; } void MicroNovaBaseListener::dump_base_config() { ESP_LOGCONFIG(TAG, @@ -18,139 +22,193 @@ void MicroNovaListener::dump_base_config() { LOG_UPDATE_INTERVAL(this); } +void MicroNovaListener::request_value_from_stove_() { + this->micronova_->queue_read_request(this->memory_location_, this->memory_address_); +} + void MicroNova::setup() { - if (this->enable_rx_pin_ != nullptr) { - this->enable_rx_pin_->setup(); - this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT); - this->enable_rx_pin_->digital_write(false); - } - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = 0; - this->current_transmission_.memory_address = 0; - this->current_transmission_.reply_pending = false; - this->current_transmission_.initiating_listener = nullptr; + this->enable_rx_pin_->setup(); + this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT); + this->enable_rx_pin_->digital_write(false); } void MicroNova::dump_config() { ESP_LOGCONFIG(TAG, "MicroNova:"); - if (this->enable_rx_pin_ != nullptr) { - LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); - } + LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); } -void MicroNova::request_update_listeners() { - ESP_LOGD(TAG, "Schedule listener update"); - for (auto &mv_listener : this->micronova_listeners_) { - mv_listener->set_needs_update(true); +#ifdef MICRONOVA_LISTENER_COUNT +void MicroNova::register_micronova_listener(MicroNovaListener *listener) { + this->listeners_.push_back(listener); + // Request initial value + this->queue_read_request(listener->get_memory_location(), listener->get_memory_address()); +} + +void MicroNova::request_update_listeners_() { + ESP_LOGD(TAG, "Requesting update from all listeners"); + for (auto *listener : this->listeners_) { + this->queue_read_request(listener->get_memory_location(), listener->get_memory_address()); } } +#endif void MicroNova::loop() { - // Only read one sensor that needs update per loop - // If STOVE_REPLY_DELAY time has passed since last loop() - // check for a reply from the stove - if ((this->current_transmission_.reply_pending) && - (millis() - this->current_transmission_.request_transmission_time > STOVE_REPLY_DELAY)) { - int stove_reply_value = this->read_stove_reply(); - if (this->current_transmission_.initiating_listener != nullptr) { - this->current_transmission_.initiating_listener->process_value_from_stove(stove_reply_value); - this->current_transmission_.initiating_listener = nullptr; - } - this->current_transmission_.reply_pending = false; - return; - } else if (!this->current_transmission_.reply_pending) { - for (auto &mv_listener : this->micronova_listeners_) { - if (mv_listener->get_needs_update()) { - mv_listener->set_needs_update(false); - this->current_transmission_.initiating_listener = mv_listener; - mv_listener->request_value_from_stove(); - return; + // Check if we're processing a command and waiting for reply + if (this->reply_pending_) { + // Check if all reply bytes have arrived + if (this->available() >= STOVE_REPLY_SIZE) { +#ifdef MICRONOVA_LISTENER_COUNT + int stove_reply_value = this->read_stove_reply_(); + if (this->current_command_.is_write()) { + if (stove_reply_value == -1) { + ESP_LOGW(TAG, "Write to [0x%02X:0x%02X] may have failed (checksum mismatch in reply)", + this->current_command_.memory_location & ~WRITE_BIT, this->current_command_.memory_address); + } + } else { + // For READ commands, notify all listeners registered for this address + uint8_t loc = this->current_command_.memory_location; + uint8_t addr = this->current_command_.memory_address; + for (auto *listener : this->listeners_) { + if (listener->get_memory_location() == loc && listener->get_memory_address() == addr) { + listener->process_value_from_stove(stove_reply_value); + } + } } +#else + this->read_stove_reply_(); +#endif + this->reply_pending_ = false; + } else if (millis() - this->transmission_time_ > STOVE_REPLY_TIMEOUT) { + // Timeout - no reply received (buffer cleared before next command) + ESP_LOGW(TAG, "Timeout waiting for reply from [0x%02X:0x%02X], available: %d", + this->current_command_.memory_location, this->current_command_.memory_address, this->available()); + this->reply_pending_ = false; } + return; } + + // No reply pending - process next command (writes have priority over reads) +#ifdef USE_MICRONOVA_WRITER + if (!this->write_queue_.empty()) { + this->current_command_ = this->write_queue_.front(); + this->write_queue_.pop(); + this->send_current_command_(); + return; + } +#endif +#ifdef MICRONOVA_LISTENER_COUNT + if (!this->read_queue_.empty()) { + this->current_command_ = this->read_queue_.front(); + this->read_queue_.pop(); + this->send_current_command_(); + } +#endif } -void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaListener *listener) { - uint8_t write_data[2] = {0, 0}; +#ifdef MICRONOVA_LISTENER_COUNT +void MicroNova::queue_read_request(uint8_t location, uint8_t address) { + // Check if this read is already queued + for (const auto &queued : this->read_queue_) { + if (queued.memory_location == location && queued.memory_address == address) { + ESP_LOGV(TAG, "Read [%02X,%02X] already queued, skipping", location, address); + return; + } + } + + MicroNovaCommand cmd; + cmd.memory_location = location; + cmd.memory_address = address; + cmd.data = 0; + + if (!this->read_queue_.push(cmd)) { + ESP_LOGW(TAG, "Read queue full, dropping read [%02X,%02X]", location, address); + return; + } + ESP_LOGV(TAG, "Queued read [%02X,%02X] (queue size: %u)", location, address, this->read_queue_.size()); +} +#endif + +void MicroNova::send_current_command_() { uint8_t trash_rx; - if (this->reply_pending_mutex_.try_lock()) { - // clear rx buffer. - // Stove hickups may cause late replies in the rx - while (this->available()) { - this->read_byte(&trash_rx); - ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx); - } - - write_data[0] = location; - write_data[1] = address; - ESP_LOGV(TAG, "Request from stove [%02X,%02X]", write_data[0], write_data[1]); - - this->enable_rx_pin_->digital_write(true); - this->write_array(write_data, 2); - this->flush(); - this->enable_rx_pin_->digital_write(false); - - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = location; - this->current_transmission_.memory_address = address; - this->current_transmission_.reply_pending = true; - this->current_transmission_.initiating_listener = listener; - } else { - ESP_LOGE(TAG, "Reply is pending, skipping read request"); + // Clear rx buffer - stove hiccups may cause late replies in the rx + while (this->available()) { + this->read_byte(&trash_rx); + ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx); } + + uint8_t write_data[4] = {this->current_command_.memory_location, this->current_command_.memory_address, 0, 0}; + size_t write_len; + + if (this->current_command_.is_write()) { + write_len = 4; + write_data[2] = this->current_command_.data; + // calculate checksum + write_data[3] = write_data[0] + write_data[1] + write_data[2]; + ESP_LOGV(TAG, "Sending write request [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], + write_data[3]); + } else { + write_len = 2; + ESP_LOGV(TAG, "Sending read request [%02X,%02X]", write_data[0], write_data[1]); + } + + this->enable_rx_pin_->digital_write(true); + this->write_array(write_data, write_len); + this->flush(); + this->enable_rx_pin_->digital_write(false); + + this->transmission_time_ = millis(); + this->reply_pending_ = true; } -int MicroNova::read_stove_reply() { +int MicroNova::read_stove_reply_() { uint8_t reply_data[2] = {0, 0}; - uint8_t checksum = 0; - // assert enable_rx_pin is false this->read_array(reply_data, 2); - this->reply_pending_mutex_.unlock(); ESP_LOGV(TAG, "Reply from stove [%02X,%02X]", reply_data[0], reply_data[1]); - checksum = ((uint16_t) this->current_transmission_.memory_location + - (uint16_t) this->current_transmission_.memory_address + (uint16_t) reply_data[1]) & - 0xFF; + uint8_t checksum = this->current_command_.memory_location + this->current_command_.memory_address + reply_data[1]; if (reply_data[0] != checksum) { - ESP_LOGE(TAG, "Checksum missmatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X", - this->current_transmission_.memory_location, this->current_transmission_.memory_address, reply_data[0], + ESP_LOGE(TAG, "Checksum mismatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X", + this->current_command_.memory_location, this->current_command_.memory_address, reply_data[0], reply_data[1], checksum, reply_data[0]); return -1; } return ((int) reply_data[1]); } -void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { - uint8_t write_data[4] = {0, 0, 0, 0}; - uint16_t checksum = 0; +#ifdef USE_MICRONOVA_WRITER +bool MicroNova::queue_write_command(uint8_t location, uint8_t address, uint8_t data) { + MicroNovaCommand cmd; + cmd.memory_location = location | WRITE_BIT; + cmd.memory_address = address; + cmd.data = data; - if (this->reply_pending_mutex_.try_lock()) { - uint8_t write_location = location | WRITE_BIT; - write_data[0] = write_location; - write_data[1] = address; - write_data[2] = data; - - checksum = ((uint16_t) write_data[0] + (uint16_t) write_data[1] + (uint16_t) write_data[2]) & 0xFF; - write_data[3] = checksum; - - ESP_LOGV(TAG, "Write 4 bytes [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], write_data[3]); - - this->enable_rx_pin_->digital_write(true); - this->write_array(write_data, 4); - this->flush(); - this->enable_rx_pin_->digital_write(false); - - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = write_location; - this->current_transmission_.memory_address = address; - this->current_transmission_.reply_pending = true; - this->current_transmission_.initiating_listener = nullptr; - } else { - ESP_LOGE(TAG, "Reply is pending, skipping write"); + // Check if a write to the same address is already queued - update data in-place + for (auto &queued : this->write_queue_) { + if (queued.memory_location == cmd.memory_location && queued.memory_address == cmd.memory_address) { + if (queued.data != cmd.data) { + ESP_LOGD(TAG, "Updating queued write [%02X,%02X] data 0x%02X -> 0x%02X", location, address, queued.data, data); + queued.data = cmd.data; + } else { + ESP_LOGV(TAG, "Write [%02X,%02X] with data 0x%02X already queued, skipping", location, address, data); + } + return true; + } } + + if (!this->write_queue_.push(cmd)) { + ESP_LOGW(TAG, "Write queue full, dropping command"); + return false; + } + ESP_LOGD(TAG, "Queued write [%02X,%02X] (queue size: %u)", location, address, this->write_queue_.size()); +#ifdef MICRONOVA_LISTENER_COUNT + // Automatically queue sensor updates after write commands + this->request_update_listeners_(); +#endif + return true; } +#endif } // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index a70f355ead4..58cca30b836 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -6,11 +6,19 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include - namespace esphome::micronova { -static const char *const TAG = "micronova"; +static constexpr uint8_t WRITE_QUEUE_SIZE = 10; + +/// Represents a command to be sent to the stove +/// Write commands have the high bit (0x80) set in memory_location +struct MicroNovaCommand { + uint8_t memory_location; + uint8_t memory_address; + uint8_t data; ///< Only used for write commands + + bool is_write() const; +}; class MicroNova; @@ -18,11 +26,8 @@ class MicroNova; // Interface classes. class MicroNovaBaseListener { public: - MicroNovaBaseListener() {} MicroNovaBaseListener(MicroNova *m) { this->micronova_ = m; } - void set_micronova_object(MicroNova *m) { this->micronova_ = m; } - void set_memory_location(uint8_t l) { this->memory_location_ = l; } uint8_t get_memory_location() { return this->memory_location_; } @@ -32,70 +37,76 @@ class MicroNovaBaseListener { void dump_base_config(); protected: - MicroNova *micronova_{nullptr}; + MicroNova *micronova_; uint8_t memory_location_ = 0; uint8_t memory_address_ = 0; }; class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent { public: - MicroNovaListener() {} MicroNovaListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void request_value_from_stove() = 0; + + void update() override { this->request_value_from_stove_(); } + virtual void process_value_from_stove(int value_from_stove) = 0; - void set_needs_update(bool u) { this->needs_update_ = u; } - bool get_needs_update() { return this->needs_update_; } - - void update() override { this->set_needs_update(true); } - void dump_base_config(); protected: - bool needs_update_ = false; -}; - -class MicroNovaButtonListener : public MicroNovaBaseListener { - public: - MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {} - - protected: - uint8_t memory_data_ = 0; + void request_value_from_stove_(); }; ///////////////////////////////////////////////////////////////////// // Main component class class MicroNova : public Component, public uart::UARTDevice { public: - MicroNova() {} + MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} void setup() override; void loop() override; void dump_config() override; - void register_micronova_listener(MicroNovaListener *l) { this->micronova_listeners_.push_back(l); } - void request_update_listeners(); - void request_address(uint8_t location, uint8_t address, MicroNovaListener *listener); - void write_address(uint8_t location, uint8_t address, uint8_t data); - int read_stove_reply(); +#ifdef MICRONOVA_LISTENER_COUNT + void register_micronova_listener(MicroNovaListener *listener); - void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; } + /// Queue a read request to the stove (low priority - added at back) + /// All listeners registered for this address will be notified with the result + /// @param location Memory location on the stove + /// @param address Memory address on the stove + void queue_read_request(uint8_t location, uint8_t address); +#endif + +#ifdef USE_MICRONOVA_WRITER + /// Queue a write command to the stove (processed before reads) + /// @param location Memory location on the stove + /// @param address Memory address on the stove + /// @param data Data to write + /// @return true if command was queued, false if queue was full + bool queue_write_command(uint8_t location, uint8_t address, uint8_t data); +#endif protected: - GPIOPin *enable_rx_pin_{nullptr}; + void send_current_command_(); + int read_stove_reply_(); +#ifdef MICRONOVA_LISTENER_COUNT + void request_update_listeners_(); +#endif - struct MicroNovaSerialTransmission { - uint32_t request_transmission_time; - uint8_t memory_location; - uint8_t memory_address; - bool reply_pending; - MicroNovaListener *initiating_listener; - }; + GPIOPin *enable_rx_pin_; - Mutex reply_pending_mutex_; - MicroNovaSerialTransmission current_transmission_; +#ifdef USE_MICRONOVA_WRITER + StaticRingBuffer write_queue_; +#endif +#ifdef MICRONOVA_LISTENER_COUNT + StaticRingBuffer read_queue_; +#endif + MicroNovaCommand current_command_{}; + uint32_t transmission_time_{0}; ///< Time when current command was sent + bool reply_pending_{false}; ///< True if we are waiting for a reply from the stove - std::vector micronova_listeners_{}; +#ifdef MICRONOVA_LISTENER_COUNT + StaticVector listeners_; +#endif }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index ef6cc0f7d7c..bcc972c5a9d 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -9,6 +9,7 @@ from .. import ( MicroNova, MicroNovaListener, micronova_ns, + register_micronova_writer, to_code_micronova_listener, ) @@ -59,22 +60,24 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): + register_micronova_writer() numb = await number.new_number( thermostat_temperature_config, + mv, min_value=0, max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) await to_code_micronova_listener(mv, numb, thermostat_temperature_config) - cg.add(numb.set_micronova_object(mv)) cg.add(numb.set_use_step_scaling(True)) if power_level_config := config.get(CONF_POWER_LEVEL): + register_micronova_writer() numb = await number.new_number( power_level_config, + mv, min_value=1, max_value=5, step=1, ) await to_code_micronova_listener(mv, numb, power_level_config) - cg.add(numb.set_micronova_object(mv)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 80279474689..02a6de1c844 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.number"; + +void MicroNovaNumber::dump_config() { + LOG_NUMBER("", "Micronova number", this); + this->dump_base_config(); +} + void MicroNovaNumber::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state(NAN); @@ -22,8 +29,7 @@ void MicroNovaNumber::control(float value) { } else { new_number = static_cast(value); } - this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); - this->micronova_->request_update_listeners(); + this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, new_number); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 3fc5838a4fc..73666b632bf 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -7,16 +7,9 @@ namespace esphome::micronova { class MicroNovaNumber : public number::Number, public MicroNovaListener { public: - MicroNovaNumber() {} MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_NUMBER("", "Micronova number", this); - this->dump_base_config(); - } + void dump_config() override; void control(float value) override; - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } void process_value_from_stove(int value_from_stove) override; void set_use_step_scaling(bool v) { this->use_step_scaling_ = v; } diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index d845e0ab3cb..8d528145cd2 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.sensor"; + +void MicroNovaSensor::dump_config() { + LOG_SENSOR("", "Micronova sensor", this); + this->dump_base_config(); +} + void MicroNovaSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state(NAN); diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index a2f232c7dc5..f3b06d140e4 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -8,14 +8,8 @@ namespace esphome::micronova { class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_SENSOR("", "Micronova sensor", this); - this->dump_base_config(); - } + void dump_config() override; - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } void process_value_from_stove(int value_from_stove) override; void set_divisor(uint8_t d) { this->divisor_ = d; } diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index c937a4cac9e..d9722b5d488 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -9,6 +9,7 @@ from .. import ( MicroNova, MicroNovaListener, micronova_ns, + register_micronova_writer, to_code_micronova_listener, ) @@ -48,6 +49,7 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): + register_micronova_writer() sw = await switch.new_switch(stove_config, mv) await to_code_micronova_listener(mv, sw, stove_config) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 9b9ad610183..d01cc572543 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -2,33 +2,42 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.switch"; + +void MicroNovaSwitch::dump_config() { + LOG_SWITCH("", "Micronova switch", this); + this->dump_base_config(); +} + void MicroNovaSwitch::write_state(bool state) { if (state) { // Only send power-on when current state is Off if (this->raw_state_ == 0) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); - this->publish_state(true); + if (this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_on_)) { + this->publish_state(true); + } } else { ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); } } else { // don't send power-off when status is Off or Final cleaning if (this->raw_state_ != 0 && this->raw_state_ != 6) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); - this->publish_state(false); + if (this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, + this->memory_data_off_)) { + this->publish_state(false); + } } else { ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } } - this->set_needs_update(true); } void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { - this->raw_state_ = value_from_stove; if (value_from_stove == -1) { ESP_LOGE(TAG, "Error reading stove state"); return; } + this->raw_state_ = value_from_stove; // set the stove switch to on for any value but 0 bool state = value_from_stove != 0; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index 96c2c14e9ef..fee3c739769 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -9,13 +9,7 @@ namespace esphome::micronova { class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_SWITCH("", "Micronova switch", this); - this->dump_base_config(); - } - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } + void dump_config() override; void process_value_from_stove(int value_from_stove) override; void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; } diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 2217ed6d6f1..50a0d34b3a7 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.text_sensor"; + +void MicroNovaTextSensor::dump_config() { + LOG_TEXT_SENSOR("", "Micronova text sensor", this); + this->dump_base_config(); +} + void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state("unknown"); diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 290f0ca45a7..6918a372e86 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -20,13 +20,7 @@ static const char *const STOVE_STATES[11] = {"Off", class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_TEXT_SENSOR("", "Micronova text sensor", this); - this->dump_base_config(); - } - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } + void dump_config() override; void process_value_from_stove(int value_from_stove) override; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 44918fe00c2..cec77fe2e27 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -107,6 +107,8 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define MICRONOVA_LISTENER_COUNT 1 +#define USE_MICRONOVA_WRITER #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 11e0afe5260..70ac1574f0c 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -293,6 +293,81 @@ template class StaticVector { operator std::span() const { return std::span(data_.data(), count_); } }; +/// Fixed-size circular buffer with FIFO semantics and iteration support. +/// +/// A tiny ring buffer that avoids dynamic allocations from std::deque/std::queue +/// (which can be wasteful on MCUs), while supporting iteration over queued elements. +/// +/// Not thread-safe. All access (push/pop/iteration) must occur from a single +/// context, or the caller must provide external synchronization. +template class StaticRingBuffer { + using index_type = std::conditional_t<(N <= 255), uint8_t, uint16_t>; + + public: + class Iterator { + public: + Iterator(StaticRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + T &operator*() { return buf_->data_[(buf_->head_ + pos_) % N]; } + Iterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const Iterator &other) const { return pos_ != other.pos_; } + + private: + StaticRingBuffer *buf_; + index_type pos_; + }; + + class ConstIterator { + public: + ConstIterator(const StaticRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + const T &operator*() const { return buf_->data_[(buf_->head_ + pos_) % N]; } + ConstIterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const ConstIterator &other) const { return pos_ != other.pos_; } + + private: + const StaticRingBuffer *buf_; + index_type pos_; + }; + + bool push(const T &value) { + if (this->count_ >= N) { + return false; + } + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % N; + ++this->count_; + return true; + } + + void pop() { + if (this->count_ > 0) { + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + } + + T &front() { return this->data_[this->head_]; } + const T &front() const { return this->data_[this->head_]; } + index_type size() const { return this->count_; } + bool empty() const { return this->count_ == 0; } + + Iterator begin() { return Iterator(this, 0); } + Iterator end() { return Iterator(this, this->count_); } + ConstIterator begin() const { return ConstIterator(this, 0); } + ConstIterator end() const { return ConstIterator(this, this->count_); } + + protected: + T data_[N]; + index_type head_{0}; + index_type tail_{0}; + index_type count_{0}; +}; + /// Fixed-capacity vector - allocates once at runtime, never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time From c52a48ed38923eccf0d5c38a70650d34e17a4f22 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:11:46 -0400 Subject: [PATCH 26/44] [multiple] Convert static function locals to member variables (#14689) Co-authored-by: Claude Opus 4.6 Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/daikin_arc/daikin_arc.cpp | 5 ++--- esphome/components/daikin_arc/daikin_arc.h | 1 + esphome/components/haier/hon_climate.cpp | 5 ++--- esphome/components/haier/hon_climate.h | 1 + .../components/ina2xx_base/ina2xx_base.cpp | 5 ++--- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 21 +++++++++---------- esphome/components/ltr_als_ps/ltr_als_ps.h | 5 ++++- .../matrix_keypad/matrix_keypad.cpp | 12 +++++------ .../components/matrix_keypad/matrix_keypad.h | 2 ++ .../components/mqtt/mqtt_backend_esp32.cpp | 9 ++++---- esphome/components/mqtt/mqtt_backend_esp32.h | 1 + esphome/components/sgp4x/sgp4x.cpp | 5 +++-- esphome/components/sgp4x/sgp4x.h | 3 ++- 13 files changed, 39 insertions(+), 36 deletions(-) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index adb7b9fec76..18f12dbfc6c 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -91,11 +91,10 @@ void DaikinArcClimate::transmit_state() { remote_state[5] = this->operation_mode_() | 0x08; remote_state[6] = this->temperature_(); remote_state[7] = this->humidity_(); - static uint8_t last_humidity = 0x66; - if (remote_state[7] != last_humidity && this->mode != climate::CLIMATE_MODE_OFF) { + if (remote_state[7] != this->last_humidity_ && this->mode != climate::CLIMATE_MODE_OFF) { ESP_LOGD(TAG, "Set Humditiy: %d, %d\n", (int) this->target_humidity, (int) remote_state[7]); remote_header[9] |= 0x10; - last_humidity = remote_state[7]; + this->last_humidity_ = remote_state[7]; } uint16_t fan_speed = this->fan_speed_(); remote_state[8] = fan_speed >> 8; diff --git a/esphome/components/daikin_arc/daikin_arc.h b/esphome/components/daikin_arc/daikin_arc.h index 6cfffd47255..2b4d4375aae 100644 --- a/esphome/components/daikin_arc/daikin_arc.h +++ b/esphome/components/daikin_arc/daikin_arc.h @@ -70,6 +70,7 @@ class DaikinArcClimate : public climate_ir::ClimateIR { // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + uint8_t last_humidity_{0x66}; }; } // namespace daikin_arc diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index be5035caa17..b8889ef2bdc 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -1375,9 +1375,8 @@ void HonClimate::process_protocol_reset() { bool HonClimate::should_get_big_data_() { if (this->big_data_sensors_ > 0) { - static uint8_t counter = 0; - counter = (counter + 1) % 3; - return counter == 1; + this->big_data_counter_ = (this->big_data_counter_ + 1) % 3; + return this->big_data_counter_ == 1; } return false; } diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 4565ed2981d..9bddac3f923 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -188,6 +188,7 @@ class HonClimate : public HaierClimateBase { float active_alarm_count_{NAN}; std::chrono::steady_clock::time_point last_alarm_request_; int big_data_sensors_{0}; + uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 9f510eef74f..2d08562e542 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -572,9 +572,8 @@ bool INA2XX::write_unsigned_16_(uint8_t reg, uint16_t val) { } bool INA2XX::read_unsigned_(uint8_t reg, uint8_t reg_size, uint64_t &data_out) { - static uint8_t rx_buf[5] = {0}; // max buffer size - - if (reg_size > 5) { + uint8_t rx_buf[5]{}; + if (reg_size > sizeof(rx_buf)) { return false; } diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index f9c1474c85a..ff335fe34c6 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -137,7 +137,6 @@ void LTRAlsPsComponent::update() { void LTRAlsPsComponent::loop() { ErrorCode err = i2c::ERROR_OK; - static uint8_t tries{0}; switch (this->state_) { case State::DELAYED_SETUP: @@ -166,20 +165,20 @@ void LTRAlsPsComponent::loop() { case State::WAITING_FOR_DATA: if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { - tries = 0; + this->read_data_tries_ = 0; ESP_LOGV(TAG, "Reading sensor data having gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); this->read_sensor_data_(this->als_readings_); this->state_ = State::DATA_COLLECTED; this->apply_lux_calculation_(this->als_readings_); - } else if (tries >= MAX_TRIES) { + } else if (this->read_data_tries_ >= MAX_TRIES) { ESP_LOGW(TAG, "Can't get data after several tries."); - tries = 0; + this->read_data_tries_ = 0; this->status_set_warning(); this->state_ = State::IDLE; return; } else { - tries++; + this->read_data_tries_++; } break; @@ -221,21 +220,21 @@ void LTRAlsPsComponent::loop() { } void LTRAlsPsComponent::check_and_trigger_ps_() { - static uint32_t last_high_trigger_time{0}; - static uint32_t last_low_trigger_time{0}; uint16_t ps_data = this->read_ps_data_(); uint32_t now = millis(); if (ps_data != this->ps_readings_) { this->ps_readings_ = ps_data; // Higher values - object is closer to sensor - if (ps_data > this->ps_threshold_high_ && now - last_high_trigger_time >= this->ps_cooldown_time_s_ * 1000) { - last_high_trigger_time = now; + if (ps_data > this->ps_threshold_high_ && + now - this->last_ps_high_trigger_time_ >= this->ps_cooldown_time_s_ * 1000) { + this->last_ps_high_trigger_time_ = now; ESP_LOGV(TAG, "Proximity high threshold triggered. Value = %d, Trigger level = %d", ps_data, this->ps_threshold_high_); this->on_ps_high_trigger_callback_.call(); - } else if (ps_data < this->ps_threshold_low_ && now - last_low_trigger_time >= this->ps_cooldown_time_s_ * 1000) { - last_low_trigger_time = now; + } else if (ps_data < this->ps_threshold_low_ && + now - this->last_ps_low_trigger_time_ >= this->ps_cooldown_time_s_ * 1000) { + this->last_ps_low_trigger_time_ = now; ESP_LOGV(TAG, "Proximity low threshold triggered. Value = %d, Trigger level = %d", ps_data, this->ps_threshold_low_); this->on_ps_low_trigger_callback_.call(); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index c6052300de8..3ab2cea074f 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -126,10 +126,13 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { MeasurementRepeatRate repeat_rate_{MeasurementRepeatRate::REPEAT_RATE_500MS}; float glass_attenuation_factor_{1.0}; + uint32_t last_ps_high_trigger_time_{0}; + uint32_t last_ps_low_trigger_time_{0}; uint16_t ps_cooldown_time_s_{5}; - PsGain ps_gain_{PsGain::PS_GAIN_16}; uint16_t ps_threshold_high_{0xffff}; uint16_t ps_threshold_low_{0x0000}; + uint8_t read_data_tries_{0}; + PsGain ps_gain_{PsGain::PS_GAIN_16}; // // Sensors for publishing data diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index febbe794e47..cc46ba98d63 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -27,8 +27,6 @@ void MatrixKeypad::setup() { } void MatrixKeypad::loop() { - static uint32_t active_start = 0; - static int active_key = -1; uint32_t now = App.get_loop_component_start_time(); int key = -1; bool error = false; @@ -54,8 +52,8 @@ void MatrixKeypad::loop() { if (error) return; - if (key != active_key) { - if ((active_key != -1) && (this->pressed_key_ == active_key)) { + if (key != this->active_key_) { + if ((this->active_key_ != -1) && (this->pressed_key_ == this->active_key_)) { row = this->pressed_key_ / this->columns_.size(); col = this->pressed_key_ % this->columns_.size(); ESP_LOGD(TAG, "key @ row %d, col %d released", row, col); @@ -70,13 +68,13 @@ void MatrixKeypad::loop() { this->pressed_key_ = -1; } - active_key = key; + this->active_key_ = key; if (key == -1) return; - active_start = now; + this->active_start_ = now; } - if ((this->pressed_key_ == key) || (now - active_start < this->debounce_time_)) + if ((this->pressed_key_ == key) || (now - this->active_start_ < this->debounce_time_)) return; row = key / this->columns_.size(); diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 258ab4fadc8..8963612d0c7 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -44,6 +44,8 @@ class MatrixKeypad : public key_provider::KeyProvider, public Component { bool has_diodes_{false}; bool has_pulldowns_{false}; int pressed_key_ = -1; + uint32_t active_start_{0}; + int active_key_{-1}; std::vector listeners_{}; std::vector key_triggers_; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 8a7fb965e96..5642fd5f7b6 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -150,17 +150,16 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { this->on_publish_.call((int) event.msg_id); break; case MQTT_EVENT_DATA: { - static std::string topic; if (!event.topic.empty()) { // When a single message arrives as multiple chunks, the topic will be empty // on any but the first message, leading to event.topic being an empty string. // To ensure handlers get the correct topic, cache the last seen topic to // simulate always receiving the topic from underlying library - topic = event.topic; + this->cached_topic_ = event.topic; } - ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", topic.c_str()); - this->on_message_.call(topic.c_str(), event.data.data(), event.data.size(), event.current_data_offset, - event.total_data_len); + ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", this->cached_topic_.c_str()); + this->on_message_.call(this->cached_topic_.c_str(), event.data.data(), event.data.size(), + event.current_data_offset, event.total_data_len); } break; case MQTT_EVENT_ERROR: ESP_LOGE(TAG, "MQTT_EVENT_ERROR"); diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index ccc4c4026c7..5c4dc413bdc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -265,6 +265,7 @@ class MQTTBackendESP32 final : public MQTTBackend { CallbackManager on_unsubscribe_; CallbackManager on_message_; CallbackManager on_publish_; + std::string cached_topic_; std::queue mqtt_events_; #if defined(USE_MQTT_IDF_ENQUEUE) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index cb41e374f82..bbf1ffd4c09 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -124,6 +124,7 @@ void SGP4xComponent::self_test_() { } this->self_test_complete_ = true; + this->nox_conditioning_start_ = millis(); ESP_LOGD(TAG, "Self-test complete"); }); } @@ -161,7 +162,6 @@ void SGP4xComponent::update_gas_indices_() { void SGP4xComponent::measure_raw_() { float humidity = NAN; - static uint32_t nox_conditioning_start = millis(); if (!this->self_test_complete_) { ESP_LOGW(TAG, "Self-test incomplete"); @@ -191,10 +191,11 @@ void SGP4xComponent::measure_raw_() { response_words = 1; } else { // SGP41 sensor must use NOx conditioning command for the first 10 seconds - if (millis() - nox_conditioning_start < 10000) { + if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { command = SGP41_CMD_NOX_CONDITIONING; response_words = 1; } else { + this->nox_conditioning_start_.reset(); command = SGP41_CMD_MEASURE_RAW; response_words = 2; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 89fa627c61c..6b8b598aff3 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -127,8 +127,9 @@ class SGP4xComponent : public PollingComponent, public sensor::Sensor, public se uint16_t measure_time_; uint8_t samples_read_ = 0; uint8_t samples_to_stabilize_ = static_cast(GasIndexAlgorithm_INITIAL_BLACKOUT) * 2; - bool store_baseline_; + + optional nox_conditioning_start_{}; ESPPreferenceObject pref_; uint32_t seconds_since_last_store_; SGP4xBaselines voc_baselines_storage_; From 4e16f270a3928738460139426748c3d96dafdddd Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 11 Mar 2026 12:47:58 -0500 Subject: [PATCH 27/44] [speaker_source] Add playlist management (#14652) --- .../components/speaker_source/automation.h | 29 ++ .../components/speaker_source/media_player.py | 43 ++ .../speaker_source_media_player.cpp | 401 ++++++++++++++---- .../speaker_source_media_player.h | 46 +- tests/components/speaker_source/common.yaml | 5 + 5 files changed, 429 insertions(+), 95 deletions(-) create mode 100644 esphome/components/speaker_source/automation.h diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h new file mode 100644 index 00000000000..b436149a03f --- /dev/null +++ b/esphome/components/speaker_source/automation.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "speaker_source_media_player.h" + +namespace esphome::speaker_source { + +template class SetPlaylistDelayAction : public Action { + public: + explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(uint8_t, pipeline) + TEMPLATABLE_VALUE(uint32_t, delay) + + void play(const Ts &...x) override { + this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...)); + } + + protected: + SpeakerSourceMediaPlayer *parent_; +}; + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index a44cdcbf01e..9080bebcae0 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -3,13 +3,16 @@ import esphome.codegen as cg from esphome.components import audio, media_player, media_source, speaker import esphome.config_validation as cv from esphome.const import ( + CONF_DELAY, CONF_FORMAT, CONF_ID, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, CONF_SPEAKER, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -19,6 +22,7 @@ CODEOWNERS = ["@kahrendt"] CONF_MEDIA_PIPELINE = "media_pipeline" CONF_ON_MUTE = "on_mute" +CONF_PIPELINE = "pipeline" CONF_ON_UNMUTE = "on_unmute" CONF_ON_VOLUME = "on_volume" CONF_SOURCES = "sources" @@ -36,6 +40,13 @@ SpeakerSourceMediaPlayer = speaker_source_ns.class_( PipelineContext = speaker_source_ns.struct("PipelineContext") Pipeline = speaker_source_ns.enum("Pipeline") +PIPELINE_ENUM = { + "media": Pipeline.MEDIA_PIPELINE, +} + +SetPlaylistDelayAction = speaker_source_ns.class_( + "SetPlaylistDelayAction", automation.Action +) FORMAT_MAPPING = { @@ -210,3 +221,35 @@ async def to_code(config: ConfigType) -> None: [(cg.float_, "x")], on_volume, ) + + +SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(SpeakerSourceMediaPlayer), + cv.Required(CONF_PIPELINE): cv.enum(PIPELINE_ENUM, lower=True), + cv.Required(CONF_DELAY): cv.templatable(cv.positive_time_period_milliseconds), + } +) + + +@automation.register_action( + "speaker_source.set_playlist_delay", + SetPlaylistDelayAction, + SET_PLAYLIST_DELAY_ACTION_SCHEMA, + synchronous=True, +) +async def set_playlist_delay_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + + cg.add(var.set_pipeline(config[CONF_PIPELINE])) + + template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) + cg.add(var.set_delay(template_)) + + return var diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index a3679891d2d..3724e206673 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -135,8 +135,12 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med PipelineContext &ps = this->pipelines_[pipeline]; if (state == media_source::MediaSourceState::IDLE) { + // Track whether this IDLE was from an orchestrator-initiated stop (e.g., NEXT/PREV/PLAY_URI) + // so we can suppress spurious PLAYLIST_ADVANCE below + bool was_stopping = (ps.stopping_source == source); + // Source went idle - clear stopping flag if this was the source we asked to stop - if (ps.stopping_source == source) { + if (was_stopping) { ps.stopping_source = nullptr; } @@ -152,6 +156,11 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med // Finish the speaker to ensure it's ready for the next playback ps.speaker->finish(); + + // Only advance the playlist if the track finished naturally (not stopped by the orchestrator) + if (!was_stopping) { + this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline); + } } } else if (state == media_source::MediaSourceState::PLAYING) { // Source started playing - make it the active source if no one else is active @@ -197,8 +206,9 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so return 0; } +// THREAD CONTEXT: Called from main loop (loop) media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_( - media_source::MediaSource *source) const { + media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const { if (source != nullptr) { switch (source->get_state()) { case media_source::MediaSourceState::PLAYING: @@ -214,97 +224,27 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat } } + // No active source. Stay PLAYING during playlist transitions + if (playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_PLAYING) { + return media_player::MEDIA_PLAYER_STATE_PLAYING; + } return media_player::MEDIA_PLAYER_STATE_IDLE; } void SpeakerSourceMediaPlayer::loop() { // Process queued control commands - MediaPlayerControlCommand control_command; - - // Use peek to check command without removing it - if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) == pdTRUE) { - bool command_executed = false; - uint8_t pipeline = control_command.pipeline; - - switch (control_command.type) { - case MediaPlayerControlCommand::PLAY_URI: { - command_executed = this->try_execute_play_uri_(*control_command.data.uri, pipeline); - break; - } - - case MediaPlayerControlCommand::SEND_COMMAND: { - PipelineContext &ps = this->pipelines_[pipeline]; - - // Determine target source: prefer active, fall back to last - media_source::MediaSource *target_source = nullptr; - if (ps.active_source != nullptr) { - target_source = ps.active_source; - } else if (ps.last_source != nullptr) { - target_source = ps.last_source; - } - - media_player::MediaPlayerCommand player_command = control_command.data.command; - switch (player_command) { - case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: { - media_source::MediaSource *active_source = ps.active_source; - if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PAUSE); - } - } else { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PLAY); - } - } - break; - } - - case media_player::MEDIA_PLAYER_COMMAND_PLAY: { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PLAY); - } - break; - } - - case media_player::MEDIA_PLAYER_COMMAND_PAUSE: { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::PAUSE); - } - break; - } - - case media_player::MEDIA_PLAYER_COMMAND_STOP: { - if (target_source != nullptr) { - target_source->handle_command(media_source::MediaSourceCommand::STOP); - } - break; - } - - default: - break; - } - - command_executed = true; - break; - } - } - - // Only remove from queue if successfully executed - if (command_executed) { - xQueueReceive(this->media_control_command_queue_, &control_command, 0); - - // Delete the allocated string for PLAY_URI commands - if (control_command.type == MediaPlayerControlCommand::PLAY_URI) { - delete control_command.data.uri; - } - } - } + this->process_control_queue_(); // Update state based on active sources media_player::MediaPlayerState old_state = this->state; PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE]; - this->state = this->get_media_pipeline_state_(media_ps.active_source); + + // Check playlist state to detect transitions between items + bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) || + (media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty()); + + this->state = this->get_media_pipeline_state_(media_ps.active_source, media_playlist_active, old_state); if (this->state != old_state) { this->publish_state(); @@ -349,9 +289,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin // Only send END command once per source - check if we've already asked this source to stop if (ps.stopping_source != active_source) { ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline); + ps.stopping_source = active_source; active_source->handle_command(media_source::MediaSourceCommand::STOP); ps.speaker->stop(); - ps.stopping_source = active_source; } return false; // Leave in queue, retry next loop } @@ -363,9 +303,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin // Only send STOP command once per source if (ps.stopping_source != target_source) { ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline); + ps.stopping_source = target_source; target_source->handle_command(media_source::MediaSourceCommand::STOP); ps.speaker->stop(); - ps.stopping_source = target_source; } return false; // Leave in queue, retry next loop } @@ -385,6 +325,7 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin if (!target_source->play_uri(uri)) { ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str()); ps.pending_source = nullptr; + this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline); } // Reset pending frame counter for this pipeline since we're starting a new source @@ -393,6 +334,280 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin return true; // Remove from queue } +// THREAD CONTEXT: Called from main loop (process_control_queue_, queue_play_current_, handle_media_state_changed_) +void SpeakerSourceMediaPlayer::queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline) { + MediaPlayerControlCommand cmd{}; + cmd.type = type; + cmd.pipeline = pipeline; + if (xQueueSend(this->media_control_command_queue_, &cmd, 0) != pdTRUE) { + ESP_LOGE(TAG, "Queue full, command dropped"); + } +} + +// THREAD CONTEXT: Called from main loop via automation commands (direct) +void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms) { + if (pipeline < this->pipelines_.size()) { + this->pipelines_[pipeline].playlist_delay_ms = delay_ms; + } +} + +// THREAD CONTEXT: Called from main loop (process_control_queue_). +// The timeout callback also runs on the main loop. +void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) { + if (delay_ms > 0) { + this->set_timeout(PipelineContext::TIMEOUT_IDS[pipeline], delay_ms, + [this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); }); + } else { + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } +} + +// THREAD CONTEXT: Called from main loop (loop) +void SpeakerSourceMediaPlayer::process_control_queue_() { + MediaPlayerControlCommand control_command; + + // Use peek to check command without removing it + if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + return; + } + + bool command_executed = false; + uint8_t pipeline = control_command.pipeline; + + // Get pipeline state + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *active_source = ps.active_source; + + switch (control_command.type) { + case MediaPlayerControlCommand::PLAY_URI: { + // Always use our local playlist to start playback + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + ps.playlist.clear(); + ps.playlist_index = 0; // Reset index + ps.playlist.push_back(*control_command.data.uri); + + // Queue PLAY_CURRENT to initiate playback + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + command_executed = true; + break; + } + + case MediaPlayerControlCommand::ENQUEUE_URI: { + // Always add to our local playlist + ps.playlist.push_back(*control_command.data.uri); + + // If nothing is playing and no upcoming items are queued, start the new item. + bool nothing_playing = + (active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE); + if (nothing_playing && ps.playlist_index >= ps.playlist.size() - 1) { + ps.playlist_index = ps.playlist.size() - 1; // Point to newly added item + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + command_executed = true; + break; + } + + case MediaPlayerControlCommand::PLAYLIST_ADVANCE: { + // Internal message: a track finished, advance to next + if (ps.repeat_mode != REPEAT_ONE) { + ps.playlist_index++; + } + + // Check if we should continue playback + if (ps.playlist_index < ps.playlist.size()) { + this->queue_play_current_(pipeline, ps.playlist_delay_ms); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = 0; + this->queue_play_current_(pipeline, ps.playlist_delay_ms); + } + command_executed = true; + break; + } + + case MediaPlayerControlCommand::PLAY_CURRENT: { + // Play the item at current playlist index + if (ps.playlist_index < ps.playlist.size()) { + command_executed = this->try_execute_play_uri_(ps.playlist[ps.playlist_index], pipeline); + } else { + command_executed = true; // Index out of bounds or empty playlist + } + break; + } + + case MediaPlayerControlCommand::SEND_COMMAND: { + this->handle_player_command_(control_command.data.command, pipeline); + command_executed = true; + break; + } + } + + // Only remove from queue if successfully executed + if (command_executed) { + xQueueReceive(this->media_control_command_queue_, &control_command, 0); + + // Delete the allocated string for PLAY_URI and ENQUEUE_URI commands + if (control_command.type == MediaPlayerControlCommand::PLAY_URI || + control_command.type == MediaPlayerControlCommand::ENQUEUE_URI) { + delete control_command.data.uri; + } + } +} + +// THREAD CONTEXT: Called from main loop only (via process_control_queue_) +void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerCommand player_command, + uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *active_source = ps.active_source; + bool has_internal_playlist = (active_source != nullptr) && active_source->has_internal_playlist(); + + // Determine target source: prefer active, fall back to last + media_source::MediaSource *target_source = nullptr; + if (active_source != nullptr) { + target_source = active_source; + } else if (ps.last_source != nullptr) { + target_source = ps.last_source; + } + + switch (player_command) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: { + // Convert TOGGLE to PLAY or PAUSE based on current state + if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + } else if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) { + bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist(); + if (last_has_internal_playlist) { + ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY); + } else { + if (ps.playlist_index >= ps.playlist.size()) { + ps.playlist_index = 0; + } + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PLAY: { + if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) { + bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist(); + if (last_has_internal_playlist) { + ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY); + } else { + if (ps.playlist_index >= ps.playlist.size()) { + ps.playlist_index = 0; + } + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_STOP: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + ps.playlist.clear(); + ps.playlist_index = 0; + } + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::STOP); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_NEXT: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + if (ps.playlist_index + 1 < ps.playlist.size()) { + ps.playlist_index++; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = 0; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::NEXT); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + if (ps.playlist_index > 0) { + ps.playlist_index--; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = ps.playlist.size() - 1; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PREVIOUS); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_ONE; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ONE); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_OFF; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_OFF); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_ALL; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ALL); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: { + if (!has_internal_playlist) { + this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + if (ps.playlist_index < ps.playlist.size()) { + ps.playlist[0] = std::move(ps.playlist[ps.playlist_index]); + ps.playlist.resize(1); + ps.playlist_index = 0; + } else { + ps.playlist.clear(); + ps.playlist_index = 0; + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST); + } + break; + } + + default: + // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL), SHUFFLE/UNSHUFFLE (PR3) are no-ops + break; + } +} + // THREAD CONTEXT: Called from main loop only. Entry points: // - HA/automation commands (direct) // - handle_play_uri_request_() via make_call().perform() (deferred from source tasks) @@ -406,10 +621,17 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call auto media_url = call.get_media_url(); if (media_url.has_value()) { - control_command.type = MediaPlayerControlCommand::PLAY_URI; + auto command = call.get_command(); + bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE; + + if (enqueue) { + control_command.type = MediaPlayerControlCommand::ENQUEUE_URI; + } else { + control_command.type = MediaPlayerControlCommand::PLAY_URI; + } // Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens - // can easily exceed 500 bytes). Deleted after the command is consumed. FreeRTOS queues require items to be - // copyable, so we store a pointer to the string in the queue rather than the string itself. + // can easily exceed 500 bytes). Deleted in process_control_queue_() after the command is consumed. FreeRTOS queues + // require items to be copyable, so we store a pointer to the string in the queue rather than the string itself. control_command.data.uri = new std::string(media_url.value()); if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { delete control_command.data.uri; @@ -454,6 +676,9 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call } media_player::MediaPlayerTraits SpeakerSourceMediaPlayer::get_traits() { + // This media player supports more traits like playlists, repeat, and shuffle, but the ESPHome API currently (March + // 2026) doesn't support those commands, so we only report pause support for now since that's used by the frontend and + // supported by our player. auto traits = media_player::MediaPlayerTraits(); traits.set_supports_pause(true); diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 7896fef295a..09670845047 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -29,7 +29,8 @@ namespace esphome::speaker_source { // - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), // handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), // set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(), -// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_() +// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(), +// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_() // // - Media source task(s): handle_media_output_() via SourceBinding::write_audio(). // Called from each source's decode task thread when streaming audio data. @@ -49,13 +50,19 @@ namespace esphome::speaker_source { // - defer(): SourceBinding::request_volume/request_mute/request_play_uri -> main loop // - Atomic fields (active_source, pending_frames): shared between all three thread contexts // -// Non-atomic pipeline fields (last_source, stopping_source, pending_source) are only accessed -// from the main loop thread. +// Non-atomic pipeline fields (last_source, stopping_source, pending_source, playlist, +// playlist_index, repeat_mode) are only accessed from the main loop thread. enum Pipeline : uint8_t { MEDIA_PIPELINE = 0, }; +enum RepeatMode : uint8_t { + REPEAT_OFF = 0, + REPEAT_ONE = 1, + REPEAT_ALL = 2, +}; + // Forward declaration class SpeakerSourceMediaPlayer; @@ -79,6 +86,9 @@ struct SourceBinding : public media_source::MediaSourceListener { }; struct PipelineContext { + /// @brief Timeout IDs for playlist delay, indexed by Pipeline enum + static constexpr const char *const TIMEOUT_IDS[] = {"next_media"}; + speaker::Speaker *speaker{nullptr}; optional format; @@ -92,6 +102,14 @@ struct PipelineContext { // Uses std::vector because the count varies across instances (multiple speaker_source media players may exist). std::vector> sources; + // Dynamic allocation is unavoidable here: URIs from Home Assistant are arbitrary-length strings + // (media URLs with tokens can easily exceed 500 bytes), and playlist size is unbounded. + // Pre-allocating fixed buffers would waste significant RAM when idle without covering worst cases. + std::vector playlist; + size_t playlist_index{0}; + RepeatMode repeat_mode{REPEAT_OFF}; + uint32_t playlist_delay_ms{0}; + // Track frames sent to speaker to correlate with playback callbacks. // Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback // callback. @@ -103,14 +121,17 @@ struct PipelineContext { struct MediaPlayerControlCommand { enum Type : uint8_t { - PLAY_URI, // Find a source that can handle this URI and play it - SEND_COMMAND, // Send command to active source + PLAY_URI, // Clear playlist, reset index, add URI, queue PLAY_CURRENT + ENQUEUE_URI, // Add URI to playlist, queue PLAY_CURRENT if idle + PLAYLIST_ADVANCE, // Advance index (or wrap for repeat_all), queue PLAY_CURRENT if more items + PLAY_CURRENT, // Play item at current playlist index (can retry if speaker not ready) + SEND_COMMAND, // Send command to active source }; Type type; uint8_t pipeline; union { - std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI) + std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI) media_player::MediaPlayerCommand command; } data; }; @@ -154,6 +175,8 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } Trigger *get_volume_trigger() { return &this->volume_trigger_; } + void set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms); + protected: // Callbacks from source bindings (pipeline index is captured at binding creation time) size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length, @@ -183,11 +206,20 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla /// @brief Determine media player state from the media pipeline's active source /// @param media_source Active source for the media pipeline (may be nullptr) + /// @param playlist_active Whether the media pipeline's playlist is in progress + /// @param old_state Previous media player state (used for transition smoothing) /// @return The appropriate MediaPlayerState - media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source) const; + media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source, + bool playlist_active, + media_player::MediaPlayerState old_state) const; + void process_control_queue_(); + void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline); bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline); media_source::MediaSource *find_source_for_uri_(const std::string &uri, uint8_t pipeline); + void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline); + void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0); + QueueHandle_t media_control_command_queue_; // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index cfcb065f57c..9e4c309c063 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -41,3 +41,8 @@ media_player: on_unmute: - media_player.play: id: media_player_id + on_volume: + - speaker_source.set_playlist_delay: + id: media_player_id + pipeline: media + delay: 500ms From 3d4ebe74ce2dc7948d2163cdf11f7f89be3c6dec Mon Sep 17 00:00:00 2001 From: Big Mike Date: Wed, 11 Mar 2026 13:00:42 -0500 Subject: [PATCH 28/44] [sensirion_common] Use SmallBufferWithHeapFallback helper (#14270) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../sensirion_common/i2c_sensirion.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 0279e08b9ff..6e244faf596 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -12,24 +12,25 @@ static const char *const TAG = "sensirion_i2c"; static const size_t BUFFER_STACK_SIZE = 16; bool SensirionI2CDevice::read_data(uint16_t *data, const uint8_t len) { - const size_t num_bytes = len * 3; - uint8_t buf[num_bytes]; + const size_t required_buffer_len = len * 3; + SmallBufferWithHeapFallback buffer(required_buffer_len); + uint8_t *temp = buffer.get(); - this->last_error_ = this->read(buf, num_bytes); + this->last_error_ = this->read(temp, required_buffer_len); if (this->last_error_ != i2c::ERROR_OK) { return false; } - for (uint8_t i = 0; i < len; i++) { - const uint8_t j = 3 * i; + for (size_t i = 0; i < len; i++) { + const size_t j = i * 3; // Use MSB first since Sensirion devices use CRC-8 with MSB first - uint8_t crc = crc8(&buf[j], 2, 0xFF, CRC_POLYNOMIAL, true); - if (crc != buf[j + 2]) { - ESP_LOGE(TAG, "CRC invalid @ %d! 0x%02X != 0x%02X", i, buf[j + 2], crc); + uint8_t crc = crc8(&temp[j], 2, 0xFF, CRC_POLYNOMIAL, true); + if (crc != temp[j + 2]) { + ESP_LOGE(TAG, "CRC invalid @ %zu! 0x%02X != 0x%02X", i, temp[j + 2], crc); this->last_error_ = i2c::ERROR_CRC; return false; } - data[i] = encode_uint16(buf[j], buf[j + 1]); + data[i] = encode_uint16(temp[j], temp[j + 1]); } return true; } From b27165a8427cb45044f7795cb46a3d6d2835cb52 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 11 Mar 2026 13:11:00 -0500 Subject: [PATCH 29/44] [speaker_source] Add shuffle support (#14653) --- .../speaker_source_media_player.cpp | 90 +++++++++++++++++-- .../speaker_source_media_player.h | 13 +++ tests/components/speaker_source/common.yaml | 4 +- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 3724e206673..cf7169df6a9 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -5,6 +5,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::speaker_source { static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; @@ -383,7 +385,8 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { // Always use our local playlist to start playback this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); ps.playlist.clear(); - ps.playlist_index = 0; // Reset index + ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist + ps.playlist_index = 0; // Reset index ps.playlist.push_back(*control_command.data.uri); // Queue PLAY_CURRENT to initiate playback @@ -396,6 +399,11 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { // Always add to our local playlist ps.playlist.push_back(*control_command.data.uri); + // If shuffle is active, add the new item to the end of the shuffle order + if (!ps.shuffle_indices.empty()) { + ps.shuffle_indices.push_back(ps.playlist.size() - 1); + } + // If nothing is playing and no upcoming items are queued, start the new item. bool nothing_playing = (active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE); @@ -425,9 +433,10 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { } case MediaPlayerControlCommand::PLAY_CURRENT: { - // Play the item at current playlist index + // Play the item at current playlist index (mapped through shuffle if active) if (ps.playlist_index < ps.playlist.size()) { - command_executed = this->try_execute_play_uri_(ps.playlist[ps.playlist_index], pipeline); + size_t actual_position = this->get_playlist_position_(pipeline); + command_executed = this->try_execute_play_uri_(ps.playlist[actual_position], pipeline); } else { command_executed = true; // Index out of bounds or empty playlist } @@ -521,6 +530,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC if (!has_internal_playlist) { this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); ps.playlist.clear(); + ps.shuffle_indices.clear(); ps.playlist_index = 0; } if (target_source != nullptr) { @@ -589,21 +599,39 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC if (!has_internal_playlist) { this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); if (ps.playlist_index < ps.playlist.size()) { - ps.playlist[0] = std::move(ps.playlist[ps.playlist_index]); + size_t actual_position = this->get_playlist_position_(pipeline); + ps.playlist[0] = std::move(ps.playlist[actual_position]); ps.playlist.resize(1); ps.playlist_index = 0; } else { ps.playlist.clear(); ps.playlist_index = 0; } + ps.shuffle_indices.clear(); } else if (target_source != nullptr) { target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST); } break; } + case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE: + if (!has_internal_playlist) { + this->shuffle_playlist_(pipeline); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::SHUFFLE); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE: + if (!has_internal_playlist) { + this->unshuffle_playlist_(pipeline); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::UNSHUFFLE); + } + break; + default: - // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL), SHUFFLE/UNSHUFFLE (PR3) are no-ops + // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL) are no-ops break; } } @@ -766,6 +794,58 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } +size_t SpeakerSourceMediaPlayer::get_playlist_position_(uint8_t pipeline) const { + const PipelineContext &ps = this->pipelines_[pipeline]; + + if (ps.shuffle_indices.empty() || ps.playlist_index >= ps.shuffle_indices.size()) { + return ps.playlist_index; + } + return ps.shuffle_indices[ps.playlist_index]; +} + +void SpeakerSourceMediaPlayer::shuffle_playlist_(uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (ps.playlist.size() <= 1) { + ps.shuffle_indices.clear(); + return; + } + + // Capture current actual position BEFORE modifying shuffle_indices + size_t current_actual = this->get_playlist_position_(pipeline); + + // Build indices vector + ps.shuffle_indices.resize(ps.playlist.size()); + for (size_t i = 0; i < ps.playlist.size(); i++) { + ps.shuffle_indices[i] = i; + } + + // Fisher-Yates shuffle using ESPHome's random helper + for (size_t i = ps.shuffle_indices.size() - 1; i > 0; i--) { + size_t j = random_uint32() % (i + 1); + std::swap(ps.shuffle_indices[i], ps.shuffle_indices[j]); + } + + // Move current track to current position (so playback continues seamlessly) + if (ps.playlist_index < ps.shuffle_indices.size()) { + for (size_t i = 0; i < ps.shuffle_indices.size(); i++) { + if (ps.shuffle_indices[i] == current_actual) { + std::swap(ps.shuffle_indices[i], ps.shuffle_indices[ps.playlist_index]); + break; + } + } + } +} + +void SpeakerSourceMediaPlayer::unshuffle_playlist_(uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (!ps.shuffle_indices.empty() && ps.playlist_index < ps.shuffle_indices.size()) { + ps.playlist_index = ps.shuffle_indices[ps.playlist_index]; + } + ps.shuffle_indices.clear(); +} + } // namespace esphome::speaker_source #endif // USE_ESP32 diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 09670845047..4fbb534110f 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -110,6 +110,10 @@ struct PipelineContext { RepeatMode repeat_mode{REPEAT_OFF}; uint32_t playlist_delay_ms{0}; + // When non-empty, playlist_index indexes into these vectors + // which contain the actual playlist indices in shuffled order + std::vector shuffle_indices; + // Track frames sent to speaker to correlate with playback callbacks. // Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback // callback. @@ -220,6 +224,15 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline); void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0); + /// @brief Maps playlist_index through shuffle indices if shuffle is active + size_t get_playlist_position_(uint8_t pipeline) const; + + /// @brief Generates shuffled indices for the playlist, keeping current track at current position + void shuffle_playlist_(uint8_t pipeline); + + /// @brief Clears shuffle indices and adjusts playlist_index to maintain current track + void unshuffle_playlist_(uint8_t pipeline); + QueueHandle_t media_control_command_queue_; // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 9e4c309c063..05b5181dfd4 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -36,10 +36,10 @@ media_player: sources: - audio_file_source on_mute: - - media_player.pause: + - media_player.shuffle: id: media_player_id on_unmute: - - media_player.play: + - media_player.unshuffle: id: media_player_id on_volume: - speaker_source.set_playlist_delay: From 03c0ce704b0c89a87f1fe2b67dbbcb6a74eb9688 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:22:48 -0400 Subject: [PATCH 30/44] Bump pyupgrade to v3.21.2 for Python 3.14 compatibility (#14699) Co-authored-by: Claude Opus 4.6 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b036da6ef16..2d8f6983959 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py311-plus] From 04bcd9f56ba24d40199f8f4cadf7761429bc942c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:25:36 -0400 Subject: [PATCH 31/44] [dashboard] Use sys.executable for dashboard subprocess commands (#14698) Co-authored-by: Jonathan Swoboda Co-authored-by: Claude Opus 4.6 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/__main__.py | 13 +++++++------ esphome/dashboard/const.py | 5 ++++- esphome/dashboard/web_server.py | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 58b995e8df4..4b0fc2cec7f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -74,6 +74,8 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] + # Maximum buffer size for serial log reading to prevent unbounded memory growth SERIAL_BUFFER_MAX_SIZE = 65536 @@ -1307,9 +1309,8 @@ def command_update_all(args: ArgsProtocol) -> int | None: files = list_yaml_files(args.configuration) def build_command(f): - if CORE.dashboard: - return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"] - return ["esphome", "run", f, "--no-logs", "--device", "OTA"] + dashboard = ["--dashboard"] if CORE.dashboard else [] + return [*ESPHOME_COMMAND, *dashboard, "run", f, "--no-logs", "--device", "OTA"] return run_multiple_configs(files, build_command) @@ -1458,7 +1459,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path.write_text(new_raw, encoding="utf-8") - rc = run_external_process("esphome", "config", str(new_path)) + rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() @@ -1476,7 +1477,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: cli_args.insert(0, "--dashboard") try: - rc = run_external_process("esphome", *cli_args) + rc = run_external_process(*ESPHOME_COMMAND, *cli_args) except KeyboardInterrupt: rc = 1 if rc != 0: @@ -1873,7 +1874,7 @@ def run_esphome(argv): # argv[0] is the program path, skip it since we prefix with "esphome" def build_command(f): return ( - ["esphome"] + [*ESPHOME_COMMAND] + [arg for arg in argv[1:] if arg not in args.configuration] + [str(f)] ) diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py index ada5575d0e1..9cadc442ef2 100644 --- a/esphome/dashboard/const.py +++ b/esphome/dashboard/const.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + from esphome.enum import StrEnum @@ -26,4 +28,5 @@ MAX_EXECUTOR_WORKERS = 48 SENTINEL = object() -DASHBOARD_COMMAND = ["esphome", "--dashboard"] +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] +DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 92cab929ef4..b8e17244e53 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -52,7 +52,7 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from ..helpers import write_file -from .const import DASHBOARD_COMMAND, DashboardEvent +from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent from .core import DASHBOARD, ESPHomeDashboard, Event from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .models import build_device_list_response @@ -1079,7 +1079,7 @@ class DownloadBinaryRequestHandler(BaseHandler): return if not path.is_file(): - args = ["esphome", "idedata", settings.rel_path(configuration)] + args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] rc, stdout, _ = await async_run_system_command(args) if rc != 0: @@ -1462,7 +1462,7 @@ class JsonConfigRequestHandler(BaseHandler): self.send_error(404) return - args = ["esphome", "config", str(filename), "--show-secrets"] + args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] rc, stdout, stderr = await async_run_system_command(args) From bef5e4de9c0a89f319c9f7124814cf7390fb922e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 11 Mar 2026 13:29:17 -0500 Subject: [PATCH 32/44] [speaker_source] Add announcement pipeline (#14654) --- .../components/speaker_source/media_player.py | 125 +++++++++++++----- .../speaker_source_media_player.cpp | 74 +++++++++-- .../speaker_source_media_player.h | 27 ++-- tests/components/speaker_source/common.yaml | 21 ++- 4 files changed, 186 insertions(+), 61 deletions(-) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 9080bebcae0..fe50536e8fd 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -20,6 +20,7 @@ DEPENDENCIES = ["media_source", "speaker"] CODEOWNERS = ["@kahrendt"] +CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" CONF_MEDIA_PIPELINE = "media_pipeline" CONF_ON_MUTE = "on_mute" CONF_PIPELINE = "pipeline" @@ -42,6 +43,19 @@ PipelineContext = speaker_source_ns.struct("PipelineContext") Pipeline = speaker_source_ns.enum("Pipeline") PIPELINE_ENUM = { "media": Pipeline.MEDIA_PIPELINE, + "announcement": Pipeline.ANNOUNCEMENT_PIPELINE, +} + +# Maps config key -> (C++ Pipeline enum value, format purpose) +_PIPELINE_INFO = { + CONF_MEDIA_PIPELINE: ( + Pipeline.MEDIA_PIPELINE, + media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], + ), + CONF_ANNOUNCEMENT_PIPELINE: ( + Pipeline.ANNOUNCEMENT_PIPELINE, + media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], + ), } SetPlaylistDelayAction = speaker_source_ns.class_( @@ -59,7 +73,7 @@ FORMAT_MAPPING = { # Returns a media_player.MediaPlayerSupportedFormat struct with the configured # format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline: ConfigType): +def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj): args = [ media_player.MediaPlayerSupportedFormat, ] @@ -68,7 +82,7 @@ def _get_supported_format_struct(pipeline: ConfigType): args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - args.append(("purpose", media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"])) + args.append(("purpose", purpose)) # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails # if the number of bytes per sample is specified for MP3. @@ -115,6 +129,40 @@ PIPELINE_SCHEMA = cv.Schema( ) +def _validate_no_shared_resources(config: ConfigType) -> ConfigType: + announcement_config = config.get(CONF_ANNOUNCEMENT_PIPELINE) + media_config = config.get(CONF_MEDIA_PIPELINE) + + # Check for duplicates within each pipeline + for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): + if pipeline_config := config.get(pipeline_key): + source_ids = [s.id for s in pipeline_config[CONF_SOURCES]] + if len(source_ids) != len(set(source_ids)): + raise cv.Invalid( + f"Duplicate media sources in {pipeline_key}. " + "Each media source can only appear once per pipeline." + ) + + # Check for sources shared between pipelines + if announcement_config and media_config: + if announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER]: + raise cv.Invalid( + "The announcement and media pipelines cannot use the same speaker. " + "Use the `mixer` speaker component to create two source speakers." + ) + + announcement_source_ids = {s.id for s in announcement_config[CONF_SOURCES]} + media_source_ids = {s.id for s in media_config[CONF_SOURCES]} + shared = announcement_source_ids & media_source_ids + if shared: + raise cv.Invalid( + f"Media sources cannot be shared between pipelines: {', '.join(shared)}. " + "Create separate media source instances for each pipeline." + ) + + return config + + def _validate_volume_settings(config: ConfigType) -> ConfigType: # CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]: @@ -131,7 +179,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage, - cv.Required(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True), cv.Optional(CONF_ON_UNMUTE): automation.validate_automation(single=True), cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True), @@ -140,23 +189,37 @@ CONFIG_SCHEMA = cv.All( .extend(cv.COMPONENT_SCHEMA) .extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)), cv.only_on_esp32, + cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE), + _validate_no_shared_resources, _validate_volume_settings, ) def _final_validate_codecs(config: ConfigType) -> ConfigType: - pipeline = config[CONF_MEDIA_PIPELINE] - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": + # "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available. + # When a specific format is set, only that codec is requested. + needed_formats: set[str] = set() + need_all = False + + for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): + if pipeline := config.get(pipeline_key): + fmt = pipeline[CONF_FORMAT] + if fmt == "NONE": + need_all = True + else: + needed_formats.add(fmt) + + if need_all: audio.request_flac_support() audio.request_mp3_support() audio.request_opus_support() - elif fmt == "FLAC": - audio.request_flac_support() - elif fmt == "MP3": - audio.request_mp3_support() - elif fmt == "OPUS": - audio.request_opus_support() + else: + if "FLAC" in needed_formats: + audio.request_flac_support() + if "MP3" in needed_formats: + audio.request_mp3_support() + if "OPUS" in needed_formats: + audio.request_opus_support() return config @@ -164,7 +227,8 @@ def _final_validate_codecs(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = cv.All( cv.Schema( { - cv.Required(CONF_MEDIA_PIPELINE): _validate_pipeline, + cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): _validate_pipeline, + cv.Optional(CONF_MEDIA_PIPELINE): _validate_pipeline, }, extra=cv.ALLOW_EXTRA, ), @@ -182,26 +246,25 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_volume_max(config[CONF_VOLUME_MAX])) cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) - pipeline_config = config[CONF_MEDIA_PIPELINE] - pipeline_enum = Pipeline.MEDIA_PIPELINE + for pipeline_key, (pipeline_enum, purpose) in _PIPELINE_INFO.items(): + if pipeline_config := config.get(pipeline_key): + for source in pipeline_config[CONF_SOURCES]: + src = await cg.get_variable(source) + cg.add(var.add_media_source(pipeline_enum, src)) - for source in pipeline_config[CONF_SOURCES]: - src = await cg.get_variable(source) - cg.add(var.add_media_source(pipeline_enum, src)) - - cg.add( - var.set_speaker( - pipeline_enum, - await cg.get_variable(pipeline_config[CONF_SPEAKER]), - ) - ) - if pipeline_config[CONF_FORMAT] != "NONE": - cg.add( - var.set_format( - pipeline_enum, - _get_supported_format_struct(pipeline_config), + cg.add( + var.set_speaker( + pipeline_enum, + await cg.get_variable(pipeline_config[CONF_SPEAKER]), + ) ) - ) + if pipeline_config[CONF_FORMAT] != "NONE": + cg.add( + var.set_format( + pipeline_enum, + _get_supported_format_struct(pipeline_config, purpose), + ) + ) if on_mute := config.get(CONF_ON_MUTE): await automation.build_automation( diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index cf7169df6a9..9d02ac9c744 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -128,6 +128,7 @@ void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const // Smart source is requesting the player to play a different URI auto call = this->make_call(); call.set_media_url(uri); + call.set_announcement(pipeline == ANNOUNCEMENT_PIPELINE); call.perform(); } @@ -209,7 +210,7 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so } // THREAD CONTEXT: Called from main loop (loop) -media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_( +media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_source_state_( media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const { if (source != nullptr) { switch (source->get_state()) { @@ -218,7 +219,7 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat case media_source::MediaSourceState::PAUSED: return media_player::MEDIA_PLAYER_STATE_PAUSED; case media_source::MediaSourceState::ERROR: - ESP_LOGE(TAG, "Source error"); + ESP_LOGE(TAG, "Media source error"); return media_player::MEDIA_PLAYER_STATE_IDLE; case media_source::MediaSourceState::IDLE: default: @@ -237,16 +238,47 @@ void SpeakerSourceMediaPlayer::loop() { // Process queued control commands this->process_control_queue_(); - // Update state based on active sources + // Update state based on active sources - announcement pipeline takes priority media_player::MediaPlayerState old_state = this->state; + PipelineContext &ann_ps = this->pipelines_[ANNOUNCEMENT_PIPELINE]; PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE]; // Check playlist state to detect transitions between items + bool announcement_playlist_active = (ann_ps.playlist_index < ann_ps.playlist.size()) || + (ann_ps.repeat_mode != REPEAT_OFF && !ann_ps.playlist.empty()); bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) || (media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty()); - this->state = this->get_media_pipeline_state_(media_ps.active_source, media_playlist_active, old_state); + // Check announcement pipeline first + media_source::MediaSource *announcement_source = ann_ps.active_source; + if (announcement_source != nullptr) { + media_source::MediaSourceState announcement_state = announcement_source->get_state(); + if (announcement_state != media_source::MediaSourceState::IDLE) { + // Announcement is active - announcements take priority and never report PAUSED + switch (announcement_state) { + case media_source::MediaSourceState::PLAYING: + case media_source::MediaSourceState::PAUSED: // Treat paused announcements as announcing + this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING; + break; + case media_source::MediaSourceState::ERROR: + ESP_LOGE(TAG, "Announcement source error"); + // Fall through to media pipeline state + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + break; + default: + break; + } + } else { + // Announcement source is idle, fall through to media pipeline + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + } + } else if (announcement_playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) { + this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING; + } else { + // No active announcement, check media pipeline + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + } if (this->state != old_state) { this->publish_state(); @@ -357,7 +389,7 @@ void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t // The timeout callback also runs on the main loop. void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) { if (delay_ms > 0) { - this->set_timeout(PipelineContext::TIMEOUT_IDS[pipeline], delay_ms, + this->set_timeout(PIPELINE_TIMEOUT_IDS[pipeline], delay_ms, [this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); }); } else { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); @@ -366,7 +398,7 @@ void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t de // THREAD CONTEXT: Called from main loop (loop) void SpeakerSourceMediaPlayer::process_control_queue_() { - MediaPlayerControlCommand control_command; + MediaPlayerControlCommand control_command{}; // Use peek to check command without removing it if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { @@ -383,7 +415,7 @@ void SpeakerSourceMediaPlayer::process_control_queue_() { switch (control_command.type) { case MediaPlayerControlCommand::PLAY_URI: { // Always use our local playlist to start playback - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); ps.playlist.clear(); ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist ps.playlist_index = 0; // Reset index @@ -528,7 +560,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_STOP: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); ps.playlist.clear(); ps.shuffle_indices.clear(); ps.playlist_index = 0; @@ -541,7 +573,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_NEXT: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); if (ps.playlist_index + 1 < ps.playlist.size()) { ps.playlist_index++; this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); @@ -557,7 +589,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); if (ps.playlist_index > 0) { ps.playlist_index--; this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); @@ -597,7 +629,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: { if (!has_internal_playlist) { - this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]); + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); if (ps.playlist_index < ps.playlist.size()) { size_t actual_position = this->get_playlist_position_(pipeline); ps.playlist[0] = std::move(ps.playlist[actual_position]); @@ -644,8 +676,24 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call return; } - MediaPlayerControlCommand control_command; - control_command.pipeline = MEDIA_PIPELINE; + MediaPlayerControlCommand control_command{}; + + // Determine which pipeline to use based on announcement flag, falling back if the preferred pipeline + // is not configured + auto announcement = call.get_announcement(); + if (announcement.has_value() && announcement.value()) { + if (this->pipelines_[ANNOUNCEMENT_PIPELINE].is_configured()) { + control_command.pipeline = ANNOUNCEMENT_PIPELINE; + } else { + control_command.pipeline = MEDIA_PIPELINE; + } + } else { + if (this->pipelines_[MEDIA_PIPELINE].is_configured()) { + control_command.pipeline = MEDIA_PIPELINE; + } else { + control_command.pipeline = ANNOUNCEMENT_PIPELINE; + } + } auto media_url = call.get_media_url(); if (media_url.has_value()) { diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 4fbb534110f..652390edd22 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -28,7 +28,7 @@ namespace esphome::speaker_source { // // - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), // handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), -// set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(), +// set_volume_(), set_mute_state_(), control(), get_source_state_(), // find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(), // process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_() // @@ -55,6 +55,7 @@ namespace esphome::speaker_source { enum Pipeline : uint8_t { MEDIA_PIPELINE = 0, + ANNOUNCEMENT_PIPELINE = 1, }; enum RepeatMode : uint8_t { @@ -85,10 +86,10 @@ struct SourceBinding : public media_source::MediaSourceListener { void request_play_uri(const std::string &uri) override; }; -struct PipelineContext { - /// @brief Timeout IDs for playlist delay, indexed by Pipeline enum - static constexpr const char *const TIMEOUT_IDS[] = {"next_media"}; +/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum +static constexpr uint32_t PIPELINE_TIMEOUT_IDS[] = {1, 2}; +struct PipelineContext { speaker::Speaker *speaker{nullptr}; optional format; @@ -132,7 +133,7 @@ struct MediaPlayerControlCommand { SEND_COMMAND, // Send command to active source }; Type type; - uint8_t pipeline; + uint8_t pipeline; // MEDIA_PIPELINE or ANNOUNCEMENT_PIPELINE union { std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI) @@ -208,14 +209,13 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla /// @brief Saves the current volume and mute state to the flash for restoration. void save_volume_restore_state_(); - /// @brief Determine media player state from the media pipeline's active source - /// @param media_source Active source for the media pipeline (may be nullptr) - /// @param playlist_active Whether the media pipeline's playlist is in progress + /// @brief Determine media player state from a pipeline's active source + /// @param media_source Active source (may be nullptr) + /// @param playlist_active Whether the pipeline's playlist is in progress /// @param old_state Previous media player state (used for transition smoothing) /// @return The appropriate MediaPlayerState - media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source, - bool playlist_active, - media_player::MediaPlayerState old_state) const; + media_player::MediaPlayerState get_source_state_(media_source::MediaSource *media_source, bool playlist_active, + media_player::MediaPlayerState old_state) const; void process_control_queue_(); void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline); @@ -235,8 +235,9 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla QueueHandle_t media_control_command_queue_; - // Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules. - std::array pipelines_; + // Pipeline context for media (index 0) and announcement (index 1) pipelines. + // See THREADING MODEL at top of namespace for access rules. + std::array pipelines_; // Used to save volume/mute state for restoration on reboot ESPPreferenceObject pref_; diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 05b5181dfd4..7d663b802c6 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -10,6 +10,11 @@ speaker: i2s_dout_pin: ${i2s_dout_pin} sample_rate: 48000 num_channels: 2 + - platform: mixer + output_speaker: speaker_id + source_speakers: + - id: announcement_mixer_speaker_id + - id: media_mixer_speaker_id audio_file: - id: test_audio @@ -19,7 +24,9 @@ audio_file: media_source: - platform: audio_file - id: audio_file_source + id: announcement_audio_file_source + - platform: audio_file + id: media_audio_file_source media_player: - platform: speaker_source @@ -29,12 +36,18 @@ media_player: volume_initial: 0.75 volume_max: 0.95 volume_min: 0.0 - media_pipeline: - speaker: speaker_id + announcement_pipeline: + speaker: announcement_mixer_speaker_id format: FLAC num_channels: 1 sources: - - audio_file_source + - announcement_audio_file_source + media_pipeline: + speaker: media_mixer_speaker_id + format: FLAC + num_channels: 1 + sources: + - media_audio_file_source on_mute: - media_player.shuffle: id: media_player_id From e7c3277eeb095a5b3e2c88ca4c7631d43528099c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:34:53 +1300 Subject: [PATCH 33/44] Bump version to 2026.4.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1a9e0b4e10f..cfdb74bd196 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.3.0-dev +PROJECT_NUMBER = 2026.4.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index d409514f3c7..33a2526d38b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0-dev" +__version__ = "2026.4.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 02f7aee680bb0a7b7a7a54e1b2cd759f8876d77c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:34:53 +1300 Subject: [PATCH 34/44] Bump version to 2026.3.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1a9e0b4e10f..9f5cd0a2ce3 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.3.0-dev +PROJECT_NUMBER = 2026.3.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index d409514f3c7..eb49c9a1d77 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0-dev" +__version__ = "2026.3.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 928f6f18660af1ffde5a1fc91f02c8a31fd43021 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 08:57:43 -1000 Subject: [PATCH 35/44] [ci] Add PR title check for unescaped angle brackets (#14701) --- .github/workflows/pr-title-check.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 198b9a6b25c..2ad023ed1b0 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -65,6 +65,18 @@ jobs: return; } + // Check for angle brackets not wrapped in backticks. + // Astro docs MDX treats bare < as JSX component opening tags. + const stripped = title.replace(/`[^`]*`/g, ''); + if (/[<>]/.test(stripped)) { + core.setFailed( + 'PR title contains `<` or `>` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components.\n' + + 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + ); + return; + } + // Check title starts with [tag] prefix const bracketPattern = /^\[\w+\]/; if (!bracketPattern.test(title)) { From b6ff7185e74da275db2bef375732b26d2e125f20 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:04:07 +1300 Subject: [PATCH 36/44] [ci] Dont run codeowners workflows on release or beta PRs (#14703) --- .github/workflows/codeowner-approved-label-update.yml | 3 +++ .github/workflows/codeowner-review-request.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 0bce33ebe29..34ff934b773 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -10,6 +10,9 @@ name: Codeowner Approved Label on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] + branches-ignore: + - release + - beta permissions: issues: write diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 02bf0e4a29e..a89c03ba042 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -13,6 +13,9 @@ on: # Needs to be pull_request_target to get write permissions pull_request_target: types: [opened, reopened, synchronize, ready_for_review] + branches-ignore: + - release + - beta permissions: pull-requests: write From 73f305ff9c9c94c3ca7e7e6a3f2b8e10749a0147 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:19 -1000 Subject: [PATCH 37/44] Bump tornado from 6.5.4 to 6.5.5 (#14704) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3da2d52b44b..e634bcb1046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.4 +tornado==6.5.5 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From a060f175ad04bf0f497a178501ca4df414b002a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:46 -1000 Subject: [PATCH 38/44] Bump actions/download-artifact from 8.0.0 to 8.0.1 (#14705) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c9e8c58bcc..461e676c4e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,13 +945,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f68e9c873f..0ed41d99c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download digests - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: digests-* path: /tmp/digests From 409640c0ee441d683b2939c2235f2bee184aecaf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:30:44 -0400 Subject: [PATCH 39/44] [esp32_hosted] Bump esp_hosted to 2.12.1 (#14708) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6d49053d6d5..a51ae2cd666 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -105,7 +105,7 @@ async def to_code(config): if framework_ver >= cv.Version(5, 5, 0): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index acd7f7a4798..f7fd3e67bc3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.0 + version: 2.12.1 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From ddc40f44fa81f41ad8970e1e607d50d646e8f2cc Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 19:56:25 -0500 Subject: [PATCH 40/44] [ethernet] ESP32-P4 Ethernet compilation fix (#14714) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/ethernet/ethernet_component.cpp | 18 +----------------- .../components/ethernet/ethernet_component.h | 2 ++ esphome/components/ethernet/ethernet_helpers.c | 8 ++++++++ .../components/ethernet/test.esp32-p4-idf.yaml | 1 + 4 files changed, 12 insertions(+), 17 deletions(-) create mode 100644 esphome/components/ethernet/ethernet_helpers.c create mode 100644 tests/components/ethernet/test.esp32-p4-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index d6b0d40cd95..e0788e11498 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -21,22 +21,6 @@ namespace esphome::ethernet { -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) -// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 -#ifdef USE_ESP32_VARIANT_ESP32P4 -#undef ETH_ESP32_EMAC_DEFAULT_CONFIG -#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ - { \ - .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ - .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ - .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ - .emac_dataif_gpio = \ - {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ - .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ - } -#endif -#endif - static const char *const TAG = "ethernet"; // PHY register size for hex logging @@ -162,7 +146,7 @@ void EthernetComponent::setup() { phy_config.phy_addr = this->phy_addr_; phy_config.reset_gpio_num = this->power_pin_; - eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index d9f05be9de0..c464e20b843 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -15,6 +15,8 @@ #include "esp_mac.h" #include "esp_idf_version.h" +extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); + namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c new file mode 100644 index 00000000000..96faccad24c --- /dev/null +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -0,0 +1,8 @@ +#include "esp_eth_mac_esp.h" + +// ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers +// which are valid in C but not in C++. This wrapper allows C++ code to get +// the default config without replicating the macro's contents. +eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { + return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); +} diff --git a/tests/components/ethernet/test.esp32-p4-idf.yaml b/tests/components/ethernet/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e52329d7ea2 --- /dev/null +++ b/tests/components/ethernet/test.esp32-p4-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ip101.yaml From 8daa946afa37e92500809cd0ad60e1fd429cb3ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 15:00:20 -1000 Subject: [PATCH 41/44] [esp32] Add crash handler to capture and report backtrace across reboots (#14709) --- esphome/components/api/api_connection.h | 6 + esphome/components/api/client.py | 21 ++ esphome/components/esp32/__init__.py | 5 + esphome/components/esp32/core.cpp | 6 + esphome/components/esp32/crash_handler.cpp | 355 +++++++++++++++++++++ esphome/components/esp32/crash_handler.h | 18 ++ esphome/components/logger/logger_esp32.cpp | 4 + esphome/core/defines.h | 1 + esphome/platformio_api.py | 7 + tests/unit_tests/test_platformio_api.py | 28 ++ 10 files changed, 451 insertions(+) create mode 100644 esphome/components/esp32/crash_handler.cpp create mode 100644 esphome/components/esp32/crash_handler.h diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3356511684f..60cc3e91b11 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -14,6 +14,9 @@ #include "api_server.h" #include "esphome/core/application.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_CRASH_HANDLER +#include "esphome/components/esp32/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -235,6 +238,9 @@ class APIConnection final : public APIServerConnectionBase { this->flags_.log_subscription = msg.level; if (msg.dump_config) App.schedule_dump_config(); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; } diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd5..0e71ad8fcbf 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime +import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard + backtrace_state = False + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = getattr(module, "process_stacktrace") + except (AttributeError, ImportError): + pass def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" + nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: ) for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + for raw_line in text.splitlines(): + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace( + config, raw_line, backtrace_state + ) + else: + backtrace_state = process_stacktrace( + config, raw_line, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 52e70501dcf..475de6aa3e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1442,6 +1442,11 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") + # Arduino already wraps esp_panic_handler for its own backtrace handler, + # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 46c000562e1..cba25bca2b2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -36,6 +37,11 @@ void arch_restart() { } void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp new file mode 100644 index 00000000000..ecf30d78781 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,355 @@ +#ifdef USE_ESP32 + +#include "esphome/core/defines.h" +#ifdef USE_ESP32_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#include +#include +#elif CONFIG_IDF_TARGET_ARCH_RISCV +#include +#endif + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +static constexpr size_t MAX_BACKTRACE = 16; + +// Check if an address looks like code (flash-mapped or IRAM). +// Must be safe to call from panic context (no flash access needed). +static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { + return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH); +} + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Check if a code address is a real return address by verifying the preceding +// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during +// panic) so flash cache is available and both IRAM and IROM are safely readable. +static inline bool is_return_addr(uint32_t addr) { + if (!is_code_addr(addr) || addr < 4) + return false; + // A return address on the stack points to the instruction after a call. + // Check for 4-byte JAL/JALR call instruction before this address. + // Use memcpy for alignment safety — RISC-V C extension means code addresses + // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. + uint32_t inst; + memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); + // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd + uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode + uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7) + // Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7) + if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80) + return true; + // Check for 2-byte compressed c.jalr before this address (C extension). + // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 + if (addr >= 2) { + uint16_t c_inst = *(uint16_t *) (addr - 2); + if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) + return true; + } + return false; +} +#endif + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset but contains garbage after power cycle. +// Validated by magic marker. Static linkage since it's only used within this file. +// Version field is first so future firmware can always identify the struct layout. +// Magic is second to validate the data. Remaining fields can change between versions. +// Version is uint32_t because it would be padded to 4 bytes anyway before the next +// uint32_t field, so we use the full width rather than wasting 3 bytes of padding. +static constexpr uint32_t CRASH_DATA_VERSION = 1; +struct RawCrashData { + uint32_t version; + uint32_t magic; + uint32_t pc; + uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) + uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) + uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) +}; +static RawCrashData __attribute__((section(".noinit"))) +s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +void crash_handler_read_and_clear() { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { + s_crash_data_valid = true; + // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data + if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count) + s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count; + if (s_raw_crash_data.exception > 4) // panic_exception_t max value + s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT + if (s_raw_crash_data.pseudo_excause > 1) + s_raw_crash_data.pseudo_excause = 0; + } + // Clear magic regardless so we don't re-report on next normal reboot + s_raw_crash_data.magic = 0; +} + +bool crash_handler_has_data() { return s_crash_data_valid; } + +// Look up the exception cause as a human-readable string. +// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays +// not exposed via any public API. +static const char *get_exception_reason() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + if (s_raw_crash_data.pseudo_excause) { + // SoC-level panic: watchdog, cache error, etc. + // Keep in sync with ESP-IDF's PANIC_RSN_* defines + static const char *const PSEUDO_REASON[] = { + "Unknown reason", // 0 + "Unhandled debug exception", // 1 + "Double exception", // 2 + "Unhandled kernel exception", // 3 + "Coprocessor exception", // 4 + "Interrupt wdt timeout on CPU0", // 5 + "Interrupt wdt timeout on CPU1", // 6 + "Cache error", // 7 + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0])) + return PSEUDO_REASON[cause]; + return PSEUDO_REASON[0]; + } + // Real Xtensa exception + static const char *const REASON[] = { + "IllegalInstruction", + "Syscall", + "InstructionFetchError", + "LoadStoreError", + "Level1Interrupt", + "Alloca", + "IntegerDivideByZero", + "PCValue", + "Privileged", + "LoadStoreAlignment", + nullptr, + nullptr, + "InstrPDAddrError", + "LoadStorePIFDataError", + "InstrPIFAddrError", + "LoadStorePIFAddrError", + "InstTLBMiss", + "InstTLBMultiHit", + "InstFetchPrivilege", + nullptr, + "InstrFetchProhibited", + nullptr, + nullptr, + nullptr, + "LoadStoreTLBMiss", + "LoadStoreTLBMultihit", + "LoadStorePrivilege", + nullptr, + "LoadProhibited", + "StoreProhibited", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // For SoC-level panics (watchdog, cache error), mcause holds IDF-internal + // interrupt numbers, not standard RISC-V cause codes. The exception type + // field already identifies these, so just return null to use the type name. + if (s_raw_crash_data.pseudo_excause) + return nullptr; + static const char *const REASON[] = { + "Instruction address misaligned", + "Instruction access fault", + "Illegal instruction", + "Breakpoint", + "Load address misaligned", + "Load access fault", + "Store address misaligned", + "Store access fault", + "Environment call from U-mode", + "Environment call from S-mode", + nullptr, + "Environment call from M-mode", + "Instruction page fault", + "Load page fault", + nullptr, + "Store page fault", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#endif + return "Unknown"; +} + +// Exception type names matching panic_exception_t enum +static const char *get_exception_type() { + static const char *const TYPES[] = { + "Debug exception", // PANIC_EXCEPTION_DEBUG + "Interrupt wdt", // PANIC_EXCEPTION_IWDT + "Task wdt", // PANIC_EXCEPTION_TWDT + "Abort", // PANIC_EXCEPTION_ABORT + "Fault", // PANIC_EXCEPTION_FAULT + }; + uint8_t exc = s_raw_crash_data.exception; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; + return "Unknown"; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!s_crash_data_valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + const char *reason = get_exception_reason(); + if (reason != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + } else { + ESP_LOGE(TAG, " Reason: %s", get_exception_type()); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + uint8_t bt_num = 0; + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif +#if CONFIG_IDF_TARGET_ARCH_RISCV + const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[256]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::esp32 + +// --- Panic handler wrapper --- +// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data +// into NOINIT memory before the normal panic handler runs. +// +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism +extern void __real_esp_panic_handler(panic_info_t *info); + +void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { + // Save the faulting PC and exception info + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; + s_raw_crash_data.exception = (uint8_t) info->exception; + s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + +#if CONFIG_IDF_TARGET_ARCH_XTENSA + // Xtensa: walk the backtrace using the public API + if (info->frame != nullptr) { + auto *xt_frame = (XtExcFrame *) info->frame; + s_raw_crash_data.cause = xt_frame->exccause; + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) xt_frame->pc, + .sp = (uint32_t) xt_frame->a1, + .next_pc = (uint32_t) xt_frame->a0, + .exc_frame = xt_frame, + }; + + uint8_t count = 0; + // First frame PC + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + s_raw_crash_data.backtrace[count++] = first_pc; + } + // Walk remaining frames + while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) { + break; + } + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + s_raw_crash_data.backtrace[count++] = pc; + } + } + s_raw_crash_data.backtrace_count = count; + } + +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // RISC-V: capture MEPC + RA, then scan stack for code addresses + if (info->frame != nullptr) { + auto *rv_frame = (RvExcFrame *) info->frame; + s_raw_crash_data.cause = rv_frame->mcause; + uint8_t count = 0; + + // Save MEPC (fault PC) and RA (return address) + if (is_code_addr(rv_frame->mepc)) { + s_raw_crash_data.backtrace[count++] = rv_frame->mepc; + } + if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { + s_raw_crash_data.backtrace[count++] = rv_frame->ra; + } + + // Track how many entries came from registers (MEPC/RA) so we can + // skip return-address validation for them at log time. + s_raw_crash_data.reg_frame_count = count; + + // Scan stack for code addresses — captures broadly during panic, + // filtered by is_return_addr() at log time when flash is accessible. + auto *scan_start = (uint32_t *) rv_frame->sp; + for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { + s_raw_crash_data.backtrace[count++] = val; + } + } + s_raw_crash_data.backtrace_count = count; + } +#endif + + // Write version and magic last — ensures all data is written before we mark it valid + s_raw_crash_data.version = CRASH_DATA_VERSION; + s_raw_crash_data.magic = CRASH_MAGIC; + + // Call the real panic handler (prints to UART, does core dump, reboots, etc.) + __real_esp_panic_handler(info); +} + +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +} // extern "C" + +#endif // USE_ESP32_CRASH_HANDLER +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 00000000000..97a4d4e1162 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP32_CRASH_HANDLER + +namespace esphome::esp32 { + +/// Read crash data from NOINIT memory and clear the magic marker. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::esp32 + +#endif // USE_ESP32_CRASH_HANDLER diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4fa..f5bf7822899 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "logger.h" +#include "esphome/components/esp32/crash_handler.h" #include #include @@ -117,6 +118,9 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cec77fe2e27..a33f10cb9c0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -195,6 +195,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESP32_CRASH_HANDLER #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_OTA_ROLLBACK diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 5d4065207f0..cb080b2a953 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -340,6 +340,8 @@ STACKTRACE_ESP32_BACKTRACE_RE = re.compile( r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" ) STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") @@ -371,6 +373,11 @@ def process_stacktrace(config, line, backtrace_state): ) _decode_pc(config, match.group(1)) + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # ESP32 single-line backtrace match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) if match is not None: diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 16861442777..e1b3908c249 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -673,6 +673,34 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = platformio_api.process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt0, False) + mock_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt1, False) + mock_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False + + def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {}) From bb7d96b954d12e99c75a02c4ebc6f12c62382942 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Thu, 12 Mar 2026 03:31:17 +0100 Subject: [PATCH 42/44] [const] Add UNIT_METER_PER_SECOND, UNIT_MILLILITRE, UNIT_POUND to const.py (#14713) --- esphome/const.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/const.py b/esphome/const.py index 33a2526d38b..29ce0303297 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1235,6 +1235,7 @@ UNIT_LITRE = "L" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" UNIT_METER = "m" +UNIT_METER_PER_SECOND = "m/s" UNIT_METER_PER_SECOND_SQUARED = "m/s²" UNIT_MICROAMP = "µA" UNIT_MICROGRAMS_PER_CUBIC_METER = "µg/m³" @@ -1244,6 +1245,7 @@ UNIT_MICROSILVERTS_PER_HOUR = "µSv/h" UNIT_MICROTESLA = "µT" UNIT_MILLIAMP = "mA" UNIT_MILLIGRAMS_PER_CUBIC_METER = "mg/m³" +UNIT_MILLILITRE = "mL" UNIT_MILLIMETER = "mm" UNIT_MILLISECOND = "ms" UNIT_MILLISIEMENS_PER_CENTIMETER = "mS/cm" @@ -1255,6 +1257,7 @@ UNIT_PARTS_PER_MILLION = "ppm" UNIT_PASCAL = "Pa" UNIT_PERCENT = "%" UNIT_PH = "pH" +UNIT_POUND = "lb" UNIT_PULSES = "pulses" UNIT_PULSES_PER_MINUTE = "pulses/min" UNIT_REVOLUTIONS_PER_MINUTE = "RPM" From 7f38d95424d0d5a29acfaba0b891d5f847320ad7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 23:48:27 -0500 Subject: [PATCH 43/44] [ethernet] ESP32-S3 Ethernet compilation fix (#14717) --- esphome/components/ethernet/ethernet_component.h | 3 +++ esphome/components/ethernet/ethernet_helpers.c | 2 ++ tests/components/ethernet/common-w5500.yaml | 4 ++-- tests/components/ethernet/test.esp32-s3-idf.yaml | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tests/components/ethernet/test.esp32-s3-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index c464e20b843..f7a0996fb74 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -11,11 +11,14 @@ #include "esp_eth.h" #include "esp_eth_mac.h" +#include "esp_eth_mac_esp.h" #include "esp_netif.h" #include "esp_mac.h" #include "esp_idf_version.h" +#if CONFIG_ETH_USE_ESP32_EMAC extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); +#endif namespace esphome::ethernet { diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c index 96faccad24c..963db3ff1c4 100644 --- a/esphome/components/ethernet/ethernet_helpers.c +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -3,6 +3,8 @@ // ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers // which are valid in C but not in C++. This wrapper allows C++ code to get // the default config without replicating the macro's contents. +#if CONFIG_ETH_USE_ESP32_EMAC eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); } +#endif diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index 1f8b8650dd0..bf3f6f3f0c4 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -2,10 +2,10 @@ ethernet: type: W5500 clk_pin: 19 mosi_pin: 21 - miso_pin: 23 + miso_pin: 17 cs_pin: 18 interrupt_pin: 36 - reset_pin: 22 + reset_pin: 12 clock_speed: 10Mhz manual_ip: static_ip: 192.168.178.56 diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..36f1b5365f1 --- /dev/null +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -0,0 +1 @@ +<<: !include common-w5500.yaml From f8a22b87b8908c33903355a74e3d593e29584f54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 19:23:01 -1000 Subject: [PATCH 44/44] [rp2040] Fix crash handler design flaws (#14716) --- esphome/components/api/api_connection.h | 6 ++++++ esphome/components/logger/logger_rp2040.cpp | 5 +++++ esphome/components/rp2040/__init__.py | 1 + esphome/components/rp2040/core.cpp | 6 +++++- esphome/components/rp2040/crash_handler.cpp | 23 ++++++++++++++++----- esphome/components/rp2040/crash_handler.h | 8 ++++++- esphome/core/defines.h | 1 + 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 60cc3e91b11..68f698d1902 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -17,6 +17,9 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -240,6 +243,9 @@ class APIConnection final : public APIServerConnectionBase { App.schedule_dump_config(); #ifdef USE_ESP32_CRASH_HANDLER esp32::crash_handler_log(); +#endif +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_log(); #endif } #ifdef USE_API_HOMEASSISTANT_SERVICES diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f76b823a8f7..b7225c2a258 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,6 +1,9 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,7 +29,9 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index b15811241ca..276187b273c 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -212,6 +212,7 @@ async def to_code(config): ) cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define("USE_RP2040_CRASH_HANDLER") def add_pio_file(component: str, key: str, data: str): diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 5e5a96c78b1..7079cbca155 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,8 +1,10 @@ #ifdef USE_RP2040 #include "core.h" -#include "crash_handler.h" #include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -25,7 +27,9 @@ void arch_restart() { } void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_read_and_clear(); +#endif #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 6ab46da4449..1f579c2d18e 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -1,5 +1,8 @@ #ifdef USE_RP2040 +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER + #include "crash_handler.h" #include "esphome/core/log.h" @@ -13,13 +16,19 @@ static constexpr uint32_t EF_LR = 5; static constexpr uint32_t EF_PC = 6; -static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD), +// lower 16 bits are the version number. This avoids using a separate scratch +// register for versioning (we only have 8 total). Future firmware reads the +// sentinel to confirm it's crash data, then the version to know the layout. +static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000; +static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION; // We only have 8 scratch registers (32 bytes) that survive watchdog reboot. // Use them for the most important data, then scan the stack for code addresses. // // Scratch register layout: -// [0] = magic (CRASH_MAGIC) +// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version) // [1] = PC (program counter at fault) // [2] = LR (link register from exception frame) // [3] = SP (stack pointer at fault) @@ -57,9 +66,12 @@ static struct { uint8_t backtrace_count; } __attribute__((section(".noinit"))) s_crash_data; +bool crash_handler_has_data() { return s_crash_data.valid; } + void crash_handler_read_and_clear() { s_crash_data.valid = false; - if (watchdog_hw->scratch[0] == CRASH_MAGIC) { + uint32_t magic = watchdog_hw->scratch[0]; + if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { s_crash_data.valid = true; s_crash_data.pc = watchdog_hw->scratch[1]; s_crash_data.lr = watchdog_hw->scratch[2]; @@ -135,7 +147,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame // by a stacking error or corrupted SP, frame may be invalid. Write a minimal // crash marker so we at least know a crash occurred. if (!is_valid_sram_ptr(frame)) { - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = 0; // PC unknown watchdog_hw->scratch[2] = 0; // LR unknown watchdog_hw->scratch[3] = reinterpret_cast(frame); // Record the bad SP for diagnosis @@ -157,7 +169,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame uint32_t pre_fault_sp = reinterpret_cast(post_frame); // Write key registers - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = frame[EF_PC]; watchdog_hw->scratch[2] = frame[EF_LR]; watchdog_hw->scratch[3] = pre_fault_sp; @@ -224,4 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h index f10db47c234..78e8ede08c8 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2040/crash_handler.h @@ -2,7 +2,9 @@ #ifdef USE_RP2040 -#include +#include "esphome/core/defines.h" + +#ifdef USE_RP2040_CRASH_HANDLER namespace esphome::rp2040 { @@ -12,6 +14,10 @@ void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + } // namespace esphome::rp2040 +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a33f10cb9c0..073170aafbf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -338,6 +338,7 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) #define USE_LOOP_PRIORITY +#define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC