From 1c67dfc850a9c280a8bd90c2e9cf90e539cf5e54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:01:34 -1000 Subject: [PATCH 01/21] Support multiple --device arguments for address fallback --- esphome/__main__.py | 136 +++++++++++++++++++++---------- esphome/components/api/client.py | 18 ++-- esphome/dashboard/web_server.py | 23 ++++-- 3 files changed, 122 insertions(+), 55 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 341c1fa893..ae450238e4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -356,7 +356,7 @@ def upload_program(config, args, host): return upload_using_esptool(config, host, file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, args.device) + return upload_using_platformio(config, host) if CORE.is_libretiny: return upload_using_platformio(config, host) @@ -379,9 +379,12 @@ def upload_program(config, args, host): remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + # Check if we should use MQTT for address resolution + # This happens when no device was specified, or the current host is "MQTT"/"OTA" + devices = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not args.device or args.device in ("MQTT", "OTA")) + and (not devices or host in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) or get_port_type(host) == "MQTT" @@ -399,23 +402,28 @@ def upload_program(config, args, host): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, port): +def show_logs(config, args, devices): if "logger" not in config: raise EsphomeError("Logger is not configured!") + + port = devices[0] + if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) if get_port_type(port) == "NETWORK" and "api" in config: + addresses_to_use = devices if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: from esphome import mqtt - port = mqtt.get_esphome_device_ip( + mqtt_address = mqtt.get_esphome_device_ip( config, args.username, args.password, args.client_id )[0] + addresses_to_use = [mqtt_address] from esphome.components.api.client import run_logs - return run_logs(config, port) + return run_logs(config, addresses_to_use) if get_port_type(port) == "MQTT" and "mqtt" in config: from esphome import mqtt @@ -478,19 +486,31 @@ def command_compile(args, config): def command_upload(args, config): - port = choose_upload_log_host( - default=args.device, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", - ) - exit_code = upload_program(config, args, port) - if exit_code != 0: - return exit_code - _LOGGER.info("Successfully uploaded program.") - return 0 + devices = args.device or [] + if not devices: + # No devices specified, use the interactive chooser + devices = [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + ] + + # Try each device until one succeeds + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + return 0 + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + return exit_code def command_discover(args, config): @@ -503,15 +523,21 @@ def command_discover(args, config): def command_logs(args, config): - port = choose_upload_log_host( - default=args.device, - check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", - ) - return show_logs(config, args, port) + devices = args.device or [] + if not devices: + # No devices specified, use the interactive chooser + devices = [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=True, + purpose="logging", + ) + ] + + return show_logs(config, args, devices) def command_run(args, config): @@ -531,29 +557,48 @@ def command_run(args, config): program_path = idedata.raw["prog_path"] return run_external_process(program_path) - port = choose_upload_log_host( - default=args.device, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", - ) - exit_code = upload_program(config, args, port) - if exit_code != 0: + devices = args.device or [] + if not devices: + # No devices specified, use the interactive chooser + devices = [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + purpose="uploading", + ) + ] + + # Try each device for upload until one succeeds + successful_device = None + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + successful_device = device + break + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + if successful_device is None: return exit_code - _LOGGER.info("Successfully uploaded program.") + if args.no_logs: return 0 + + # For logs, prefer the device we successfully uploaded to port = choose_upload_log_host( - default=args.device, - check_default=port, + default=successful_device, + check_default=successful_device, show_ota=False, show_mqtt=True, show_api=True, purpose="logging", ) - return show_logs(config, args, port) + return show_logs(config, args, [port]) def command_clean_mqtt(args, config): @@ -854,7 +899,8 @@ def parse_args(argv): ) parser_upload.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_upload.add_argument( "--upload_speed", @@ -876,7 +922,8 @@ def parse_args(argv): ) parser_logs.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_logs.add_argument( "--reset", @@ -905,7 +952,8 @@ def parse_args(argv): ) parser_run.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_run.add_argument( "--upload_speed", diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 5239e07435..ce018b3b98 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -async def async_run_logs(config: dict[str, Any], address: str) -> None: +async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command in the event loop.""" conf = config["api"] name = config["esphome"]["name"] @@ -39,13 +39,21 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: noise_psk: str | None = None if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): noise_psk = key - _LOGGER.info("Starting log output from %s using esphome API", address) + + if len(addresses) == 1: + _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) + else: + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + cli = APIClient( - address, + addresses[0], # Primary address for compatibility port, password, client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard @@ -66,7 +74,7 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: await stop() -def run_logs(config: dict[str, Any], address: str) -> None: +def run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command.""" with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_run_logs(config, address)) + asyncio.run(async_run_logs(config, addresses)) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 286dc9e1d7..6519a85f89 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,6 +324,8 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] + addresses: list[str] = [] + if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) @@ -333,10 +335,10 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): if (mdns := dashboard.mdns_status) and ( address_list := await mdns.async_resolve_host(entry.name) ): - # Use the IP address if available but only + # Use all IP addresses if available but only # if the API is loaded and the device is online # since MQTT logging will not work otherwise - port = sort_ip_addresses(address_list)[0] + addresses = sort_ip_addresses(address_list) elif ( entry.address and ( @@ -347,16 +349,25 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and not isinstance(address_list, Exception) ): # If mdns is not available, try to use the DNS cache - port = sort_ip_addresses(address_list)[0] + addresses = sort_ip_addresses(address_list) - return [ + # Build command with multiple --device arguments for each address + command = [ *DASHBOARD_COMMAND, *args, config_file, - "--device", - port, ] + if addresses: + # Add multiple --device arguments for each resolved address + for address in addresses: + command.extend(["--device", address]) + else: + # Fallback to original port if no addresses were resolved + command.extend(["--device", port]) + + return command + class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: From 13e9350568753567d10536389b1c2deb161c36b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:13:42 -1000 Subject: [PATCH 02/21] cleanup --- esphome/dashboard/web_server.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 6519a85f89..0fefad0ed1 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -332,14 +332,8 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and entry.loaded_integrations and "api" in entry.loaded_integrations ): - if (mdns := dashboard.mdns_status) and ( - address_list := await mdns.async_resolve_host(entry.name) - ): - # Use all IP addresses if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - addresses = sort_ip_addresses(address_list) - elif ( + # First priority: use_address from configuration + if ( entry.address and ( address_list := await dashboard.dns_cache.async_resolve( @@ -348,7 +342,14 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ) and not isinstance(address_list, Exception) ): - # If mdns is not available, try to use the DNS cache + addresses = sort_ip_addresses(address_list) + # Second priority: mDNS resolved addresses + elif (mdns := dashboard.mdns_status) and ( + address_list := await mdns.async_resolve_host(entry.name) + ): + # Use all IP addresses if available but only + # if the API is loaded and the device is online + # since MQTT logging will not work otherwise addresses = sort_ip_addresses(address_list) # Build command with multiple --device arguments for each address From 4caf2b70429e8b4405c0a9089f064b3161cfeb0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:16:15 -1000 Subject: [PATCH 03/21] cleanup --- esphome/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index ae450238e4..54e3529096 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -402,7 +402,7 @@ def upload_program(config, args, host): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, devices): +def show_logs(config, args, devices: list[str]): if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -485,7 +485,7 @@ def command_compile(args, config): return 0 -def command_upload(args, config): +def command_upload(args, config) -> int: devices = args.device or [] if not devices: # No devices specified, use the interactive chooser @@ -522,7 +522,7 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config): +def command_logs(args, config) -> int: devices = args.device or [] if not devices: # No devices specified, use the interactive chooser From d3f103c789d84c6c77fe97e3b0c14a763b7aae11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:28:04 -1000 Subject: [PATCH 04/21] make entry.address take priority over mdns --- esphome/dashboard/web_server.py | 37 +++++++++++++-------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 0fefad0ed1..8489c6f09c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,15 +324,15 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] - addresses: list[str] = [] - + addresses: list[str] = [port] if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - # First priority: use_address from configuration + addresses = [] + # First priority: entry.address AKA use_address if ( entry.address and ( @@ -342,32 +342,23 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ) and not isinstance(address_list, Exception) ): - addresses = sort_ip_addresses(address_list) - # Second priority: mDNS resolved addresses - elif (mdns := dashboard.mdns_status) and ( + addresses.extend(sort_ip_addresses(address_list)) + + # Second priority: mDNS + if (mdns := dashboard.mdns_status) and ( address_list := await mdns.async_resolve_host(entry.name) ): - # Use all IP addresses if available but only + # Use the IP address if available but only # if the API is loaded and the device is online # since MQTT logging will not work otherwise - addresses = sort_ip_addresses(address_list) + addresses.extend(sort_ip_addresses(address_list)) - # Build command with multiple --device arguments for each address - command = [ - *DASHBOARD_COMMAND, - *args, - config_file, - ] + device_args: list[str] = [] + for address in addresses: + device_args.append("--device") + device_args.append(address) - if addresses: - # Add multiple --device arguments for each resolved address - for address in addresses: - command.extend(["--device", address]) - else: - # Fallback to original port if no addresses were resolved - command.extend(["--device", port]) - - return command + return [*DASHBOARD_COMMAND, *args, config_file, *device_args] class EsphomeLogsHandler(EsphomePortCommandWebSocket): From 1161bfcc93410a3594fdc4cce97ca5ede56a784c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:35:49 -1000 Subject: [PATCH 05/21] preen --- esphome/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 54e3529096..3f17cd57cf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -341,7 +341,7 @@ def check_permissions(port): ) -def upload_program(config, args, host): +def upload_program(config, args, host: str): try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -381,7 +381,7 @@ def upload_program(config, args, host): # Check if we should use MQTT for address resolution # This happens when no device was specified, or the current host is "MQTT"/"OTA" - devices = args.device or [] + devices: list[str] = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions and (not devices or host in ("MQTT", "OTA")) @@ -486,7 +486,7 @@ def command_compile(args, config): def command_upload(args, config) -> int: - devices = args.device or [] + devices: list[str] = args.device or [] if not devices: # No devices specified, use the interactive chooser devices = [ From 5fac039a06b5adb356f808e0b92ee5839ddf7a91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:39:08 -1000 Subject: [PATCH 06/21] preen --- esphome/__main__.py | 68 +++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3f17cd57cf..d133b152f5 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -44,6 +44,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, coroutine 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 ( get_serial_ports, list_yaml_files, @@ -123,7 +124,7 @@ def mqtt_logging_enabled(mqtt_config): return log_topic.get(CONF_LEVEL, None) != "NONE" -def get_port_type(port): +def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" if port == "MQTT": @@ -131,7 +132,7 @@ def get_port_type(port): return "NETWORK" -def run_miniterm(config, port, args): +def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial @@ -249,7 +250,7 @@ def compile_program(args, config): return 0 if idedata is not None else 1 -def upload_using_esptool(config, port, file, speed): +def upload_using_esptool(config: ConfigType, port: str, file: str, speed: int): from esphome import platformio_api first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( @@ -314,7 +315,7 @@ def upload_using_esptool(config, port, file, speed): return run_esptool(115200) -def upload_using_platformio(config, port): +def upload_using_platformio(config: ConfigType, port: str): from esphome import platformio_api upload_args = ["-t", "upload", "-t", "nobuild"] @@ -323,7 +324,7 @@ def upload_using_platformio(config, port): return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) -def check_permissions(port): +def check_permissions(port: str): if os.name == "posix" and get_port_type(port) == "SERIAL": # Check if we can open selected serial port if not os.access(port, os.F_OK): @@ -341,7 +342,7 @@ def check_permissions(port): ) -def upload_program(config, args, host: str): +def upload_program(config: ConfigType, args, host: str): try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -402,7 +403,7 @@ def upload_program(config, args, host: str): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, devices: list[str]): +def show_logs(config: ConfigType, args, devices: list[str]) -> int | None: if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -522,21 +523,18 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config) -> int: - devices = args.device or [] - if not devices: - # No devices specified, use the interactive chooser - devices = [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", - ) - ] - +def command_logs(args, config) -> int | None: + # No devices specified, use the interactive chooser + devices = args.device or [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=True, + purpose="logging", + ) + ] return show_logs(config, args, devices) @@ -557,22 +555,20 @@ def command_run(args, config): program_path = idedata.raw["prog_path"] return run_external_process(program_path) - devices = args.device or [] - if not devices: - # No devices specified, use the interactive chooser - devices = [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", - ) - ] + # No devices specified, use the interactive chooser + devices = args.device or [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + purpose="uploading", + ) + ] # Try each device for upload until one succeeds - successful_device = None + successful_device: str | None = None for device in devices: _LOGGER.info("Uploading to %s", device) exit_code = upload_program(config, args, device) From b96cd2b932f1e816de19aaad8dc322fe2bb552ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:41:04 -1000 Subject: [PATCH 07/21] preen --- esphome/__main__.py | 6 ++++-- esphome/util.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d133b152f5..4bd611d50c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -250,7 +250,9 @@ def compile_program(args, config): return 0 if idedata is not None else 1 -def upload_using_esptool(config: ConfigType, port: str, file: str, speed: int): +def upload_using_esptool( + config: ConfigType, port: str, file: str, speed: int +) -> str | int: from esphome import platformio_api first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( @@ -342,7 +344,7 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args, host: str): +def upload_program(config: ConfigType, args, host: str) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): diff --git a/esphome/util.py b/esphome/util.py index 3b346371bc..395d4a7351 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -223,7 +223,7 @@ def run_external_command( return retval -def run_external_process(*cmd, **kwargs): +def run_external_process(*cmd: str, **kwargs: str) -> int | str: full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") From 264fbb40292ab8191602dcc949a321c46afda945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:45:54 -1000 Subject: [PATCH 08/21] preen --- esphome/__main__.py | 49 +++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4bd611d50c..3edccc2bdb 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -9,6 +9,7 @@ import os import re import sys import time +from typing import Protocol import argcomplete @@ -56,6 +57,20 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +class ArgsProtocol(Protocol): + device: list[str] | None + reset: bool + username: str | None + password: str | None + client_id: str | None + topic: str | None + file: str | None + no_logs: bool + only_generate: bool + show_secrets: bool + dashboard: bool + + def choose_prompt(options, purpose: str = None): if not options: raise EsphomeError( @@ -344,7 +359,7 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args, host: str) -> int | str: +def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -405,7 +420,7 @@ def upload_program(config: ConfigType, args, host: str) -> int | str: return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config: ConfigType, args, devices: list[str]) -> int | None: +def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -437,7 +452,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> int | None: raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") -def clean_mqtt(config, args): +def clean_mqtt(config: ConfigType, args: ArgsProtocol) -> int | None: from esphome import mqtt return mqtt.clear_topic( @@ -445,13 +460,13 @@ def clean_mqtt(config, args): ) -def command_wizard(args): +def command_wizard(args: ArgsProtocol) -> int | None: from esphome import wizard return wizard.wizard(args.configuration) -def command_config(args, config): +def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) @@ -466,7 +481,7 @@ def command_config(args, config): return 0 -def command_vscode(args): +def command_vscode(args: ArgsProtocol) -> int | None: from esphome import vscode logging.disable(logging.INFO) @@ -474,7 +489,7 @@ def command_vscode(args): vscode.read_config(args) -def command_compile(args, config): +def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -488,7 +503,7 @@ def command_compile(args, config): return 0 -def command_upload(args, config) -> int: +def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: devices: list[str] = args.device or [] if not devices: # No devices specified, use the interactive chooser @@ -516,7 +531,7 @@ def command_upload(args, config) -> int: return exit_code -def command_discover(args, config): +def command_discover(args: ArgsProtocol, config: ConfigType) -> int | None: if "mqtt" in config: from esphome import mqtt @@ -525,7 +540,7 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config) -> int | None: +def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: # No devices specified, use the interactive chooser devices = args.device or [ choose_upload_log_host( @@ -540,7 +555,7 @@ def command_logs(args, config) -> int | None: return show_logs(config, args, devices) -def command_run(args, config): +def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -599,22 +614,22 @@ def command_run(args, config): return show_logs(config, args, [port]) -def command_clean_mqtt(args, config): +def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) -def command_mqtt_fingerprint(args, config): +def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import mqtt return mqtt.get_fingerprint(config) -def command_version(args): +def command_version(args: ArgsProtocol) -> int | None: safe_print(f"Version: {const.__version__}") return 0 -def command_clean(args, config): +def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: try: writer.clean_build() except OSError as err: @@ -624,13 +639,13 @@ def command_clean(args, config): return 0 -def command_dashboard(args): +def command_dashboard(args: ArgsProtocol) -> int | None: from esphome.dashboard import dashboard return dashboard.start_dashboard(args) -def command_update_all(args): +def command_update_all(args: ArgsProtocol) -> int | None: import click success = {} From 082d741066be2761b516ea4d4d8b4e5a7e89b00d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:46:40 -1000 Subject: [PATCH 09/21] preen --- esphome/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3edccc2bdb..eb34fb74b6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -692,7 +692,7 @@ def command_update_all(args: ArgsProtocol) -> int | None: return failed -def command_idedata(args, config): +def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json from esphome import platformio_api @@ -708,7 +708,7 @@ def command_idedata(args, config): return 0 -def command_rename(args, config): +def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: for c in args.name: if c not in ALLOWED_NAME_CHARS: print( From 8a15d2ea8cc758491ec5a90da3192bddf1e594df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:51:21 -1000 Subject: [PATCH 10/21] preen --- esphome/util.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/util.py b/esphome/util.py index 395d4a7351..047ea8ecea 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -110,7 +110,7 @@ class RedirectText: def __getattr__(self, item): return getattr(self._out, item) - def _write_color_replace(self, s): + def _write_color_replace(self, s: str | bytes) -> None: from esphome.core import CORE if CORE.dashboard: @@ -121,7 +121,7 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) - def write(self, s): + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a # str @@ -266,7 +266,7 @@ class OrderedDict(collections.OrderedDict): return dict(self).__repr__() -def list_yaml_files(folders): +def list_yaml_files(folders: list[str]) -> list[str]: files = filter_yaml_files( [os.path.join(folder, p) for folder in folders for p in os.listdir(folder)] ) @@ -274,7 +274,7 @@ def list_yaml_files(folders): return files -def filter_yaml_files(files): +def filter_yaml_files(files: list[str]) -> list[str]: return [ f for f in files From 837863568f9a763c2b310d084bc232f48c16802e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:52:34 -1000 Subject: [PATCH 11/21] preen --- esphome/__main__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index eb34fb74b6..97ac8388cf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -69,6 +69,9 @@ class ArgsProtocol(Protocol): only_generate: bool show_secrets: bool dashboard: bool + configuration: str + name: str + upload_speed: str | None def choose_prompt(options, purpose: str = None): @@ -224,7 +227,7 @@ def wrap_to_code(name, comp): return wrapped -def write_cpp(config): +def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -232,7 +235,7 @@ def write_cpp(config): return write_cpp_file() -def generate_cpp_contents(config): +def generate_cpp_contents(config: ConfigType) -> None: _LOGGER.info("Generating C++ source...") for name, component, conf in iter_component_configs(CORE.config): @@ -243,7 +246,7 @@ def generate_cpp_contents(config): CORE.flush_tasks() -def write_cpp_file(): +def write_cpp_file() -> int: code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) @@ -254,7 +257,7 @@ def write_cpp_file(): return 0 -def compile_program(args, config): +def compile_program(args: ArgsProtocol, config: ConfigType) -> int: from esphome import platformio_api _LOGGER.info("Compiling app...") From 65e2c20bcf2ba0d967d816be771ac77a433e4d2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:02:25 -1000 Subject: [PATCH 12/21] preen --- esphome/dashboard/web_server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 8489c6f09c..2e93fdf0d0 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -353,10 +353,9 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): # since MQTT logging will not work otherwise addresses.extend(sort_ip_addresses(address_list)) - device_args: list[str] = [] - for address in addresses: - device_args.append("--device") - device_args.append(address) + device_args: list[str] = [ + arg for address in addresses for arg in ("--device", address) + ] return [*DASHBOARD_COMMAND, *args, config_file, *device_args] From 204da1af8b367a640096ad68f7486ed5aa425293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:03:31 -1000 Subject: [PATCH 13/21] preen --- esphome/__main__.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 97ac8388cf..f0d85059d1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -507,19 +507,17 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: - devices: list[str] = args.device or [] - if not devices: - # No devices specified, use the interactive chooser - devices = [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", - ) - ] + # No devices specified, use the interactive chooser + devices: list[str] = args.device or [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + ] # Try each device until one succeeds for device in devices: From 42fe7d9fb24ff6b4df41e4ffa28e67791a88d4b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:05:34 -1000 Subject: [PATCH 14/21] preen --- esphome/dashboard/web_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 2e93fdf0d0..9efd06aaca 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -334,10 +334,10 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): addresses = [] # First priority: entry.address AKA use_address if ( - entry.address + (use_address := entry.address) and ( address_list := await dashboard.dns_cache.async_resolve( - entry.address, time.monotonic() + use_address, time.monotonic() ) ) and not isinstance(address_list, Exception) From bea2f4971eec1cd17874f06e73bd812fbc1e7be2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:07:06 -1000 Subject: [PATCH 15/21] preen --- esphome/dashboard/web_server.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9efd06aaca..46f09336bb 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -345,13 +345,19 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): addresses.extend(sort_ip_addresses(address_list)) # Second priority: mDNS - if (mdns := dashboard.mdns_status) and ( - address_list := await mdns.async_resolve_host(entry.name) + if ( + (mdns := dashboard.mdns_status) + and (address_list := await mdns.async_resolve_host(entry.name)) + and ( + new_addresses := [ + addr for addr in address_list if addr not in addresses + ] + ) ): # Use the IP address if available but only # if the API is loaded and the device is online # since MQTT logging will not work otherwise - addresses.extend(sort_ip_addresses(address_list)) + addresses.extend(sort_ip_addresses(new_addresses)) device_args: list[str] = [ arg for address in addresses for arg in ("--device", address) From e17af87f6e812ca0dfef7a1de5b7ebda27db59e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 22:25:44 -1000 Subject: [PATCH 16/21] preen --- esphome/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/util.py b/esphome/util.py index 047ea8ecea..8fc65967b9 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -6,6 +6,7 @@ from pathlib import Path import re import subprocess import sys +from typing import Any from esphome import const @@ -223,7 +224,7 @@ def run_external_command( return retval -def run_external_process(*cmd: str, **kwargs: str) -> int | str: +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") From 52c450920851bece64c975222a7eff1fe3fec231 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:31:56 +1200 Subject: [PATCH 17/21] [esp32_dac] Always use esp-idf APIs (#9833) --- esphome/components/esp32_dac/esp32_dac.cpp | 21 +++------------------ esphome/components/esp32_dac/esp32_dac.h | 12 ++++-------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp32_dac/esp32_dac.cpp b/esphome/components/esp32_dac/esp32_dac.cpp index 7d8507c566..8f226a5cc2 100644 --- a/esphome/components/esp32_dac/esp32_dac.cpp +++ b/esphome/components/esp32_dac/esp32_dac.cpp @@ -2,11 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - -#ifdef USE_ARDUINO -#include -#endif +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) namespace esphome { namespace esp32_dac { @@ -23,18 +19,12 @@ void ESP32DAC::setup() { this->pin_->setup(); this->turn_off(); -#ifdef USE_ESP_IDF const dac_channel_t channel = this->pin_->get_pin() == DAC0_PIN ? DAC_CHAN_0 : DAC_CHAN_1; const dac_oneshot_config_t oneshot_cfg{channel}; dac_oneshot_new_channel(&oneshot_cfg, &this->dac_handle_); -#endif } -void ESP32DAC::on_safe_shutdown() { -#ifdef USE_ESP_IDF - dac_oneshot_del_channel(this->dac_handle_); -#endif -} +void ESP32DAC::on_safe_shutdown() { dac_oneshot_del_channel(this->dac_handle_); } void ESP32DAC::dump_config() { ESP_LOGCONFIG(TAG, "ESP32 DAC:"); @@ -48,15 +38,10 @@ void ESP32DAC::write_state(float state) { state = state * 255; -#ifdef USE_ESP_IDF dac_oneshot_output_voltage(this->dac_handle_, state); -#endif -#ifdef USE_ARDUINO - dacWrite(this->pin_->get_pin(), state); -#endif } } // namespace esp32_dac } // namespace esphome -#endif +#endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 63d0c914a1..95c687d307 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -1,15 +1,13 @@ #pragma once +#include "esphome/components/output/float_output.h" +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" -#include "esphome/components/output/float_output.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) -#ifdef USE_ESP_IDF #include -#endif namespace esphome { namespace esp32_dac { @@ -29,12 +27,10 @@ class ESP32DAC : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; -#ifdef USE_ESP_IDF dac_oneshot_handle_t dac_handle_; -#endif }; } // namespace esp32_dac } // namespace esphome -#endif +#endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 From 396c02c6de4bff1533f3fcbcf682a8ce61d1194a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:33:12 +1200 Subject: [PATCH 18/21] [core] Allow extra args on cli and just ignore them (#9814) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5e45b7f213..47e1c774ac 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -767,6 +767,12 @@ POST_CONFIG_ACTIONS = { "discover": command_discover, } +SIMPLE_CONFIG_ACTIONS = [ + "clean", + "clean-mqtt", + "config", +] + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) @@ -1032,6 +1038,13 @@ def parse_args(argv): arguments = argv[1:] argcomplete.autocomplete(parser) + + if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: + args, unknown_args = parser.parse_known_args(arguments) + if unknown_args: + _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) + return args + return parser.parse_args(arguments) From c85eb448e4003a28f72c689ef774bb8a08874aba Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:45:52 +1200 Subject: [PATCH 19/21] [gpio_expander] Fix bank caching (#10077) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/gpio_expander/cached_gpio.h | 39 +++--- .../gpio_expander_test_component/__init__.py | 25 ++++ .../gpio_expander_test_component.cpp | 38 ++++++ .../gpio_expander_test_component.h | 18 +++ .../fixtures/gpio_expander_cache.yaml | 17 +++ tests/integration/test_gpio_expander_cache.py | 123 ++++++++++++++++++ 6 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h create mode 100644 tests/integration/fixtures/gpio_expander_cache.yaml create mode 100644 tests/integration/test_gpio_expander_cache.py diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index 78c675cdb2..d7230eb0b3 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -2,10 +2,11 @@ #include #include +#include +#include #include "esphome/core/hal.h" -namespace esphome { -namespace gpio_expander { +namespace esphome::gpio_expander { /// @brief A class to cache the read state of a GPIO expander. /// This class caches reads between GPIO Pins which are on the same bank. @@ -17,12 +18,22 @@ namespace gpio_expander { /// N - Number of pins template class CachedGpioExpander { public: + /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. + /// @param pin Pin number to read + /// @return Pin state bool digital_read(T pin) { - uint8_t bank = pin / (sizeof(T) * BITS_PER_BYTE); - if (this->read_cache_invalidated_[bank]) { - this->read_cache_invalidated_[bank] = false; + const uint8_t bank = pin / BANK_SIZE; + const T pin_mask = (1 << (pin % BANK_SIZE)); + // Check if specific pin cache is valid + if (this->read_cache_valid_[bank] & pin_mask) { + // Invalidate pin + this->read_cache_valid_[bank] &= ~pin_mask; + } else { + // Read whole bank from hardware if (!this->digital_read_hw(pin)) return false; + // Mark bank cache as valid except the pin that is being returned now + this->read_cache_valid_[bank] = std::numeric_limits::max() & ~pin_mask; } return this->digital_read_cache(pin); } @@ -36,18 +47,16 @@ template class CachedGpioExpander { virtual bool digital_read_cache(T pin) = 0; /// @brief Call component low level function to write GPIO state to device virtual void digital_write_hw(T pin, bool value) = 0; - const uint8_t cache_byte_size_ = N / (sizeof(T) * BITS_PER_BYTE); /// @brief Invalidate cache. This function should be called in component loop(). - void reset_pin_cache_() { - for (T i = 0; i < this->cache_byte_size_; i++) { - this->read_cache_invalidated_[i] = true; - } - } + void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } - static const uint8_t BITS_PER_BYTE = 8; - std::array read_cache_invalidated_{}; + static constexpr uint8_t BITS_PER_BYTE = 8; + static constexpr uint8_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; + static constexpr size_t BANKS = N / BANK_SIZE; + static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); + + T read_cache_valid_[BANKS]{0}; }; -} // namespace gpio_expander -} // namespace esphome +} // namespace esphome::gpio_expander diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py b/tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py new file mode 100644 index 0000000000..5672f80004 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["gpio_expander"] + +gpio_expander_test_component_ns = cg.esphome_ns.namespace( + "gpio_expander_test_component" +) + +GPIOExpanderTestComponent = gpio_expander_test_component_ns.class_( + "GPIOExpanderTestComponent", cg.Component +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GPIOExpanderTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp new file mode 100644 index 0000000000..7e88950592 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp @@ -0,0 +1,38 @@ +#include "gpio_expander_test_component.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::gpio_expander_test_component { + +static const char *const TAG = "gpio_expander_test"; + +void GPIOExpanderTestComponent::setup() { + for (uint8_t pin = 0; pin < 32; pin++) { + this->digital_read(pin); + } + + this->digital_read(3); + this->digital_read(3); + this->digital_read(4); + this->digital_read(3); + this->digital_read(10); + this->reset_pin_cache_(); // Reset cache to ensure next read is from hardware + this->digital_read(15); + this->digital_read(14); + this->digital_read(14); + + ESP_LOGD(TAG, "DONE"); +} + +bool GPIOExpanderTestComponent::digital_read_hw(uint8_t pin) { + ESP_LOGD(TAG, "digital_read_hw pin=%d", pin); + return true; +} + +bool GPIOExpanderTestComponent::digital_read_cache(uint8_t pin) { + ESP_LOGD(TAG, "digital_read_cache pin=%d", pin); + return true; +} + +} // namespace esphome::gpio_expander_test_component diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h new file mode 100644 index 0000000000..ffaee2cd65 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" + +namespace esphome::gpio_expander_test_component { + +class GPIOExpanderTestComponent : public Component, public esphome::gpio_expander::CachedGpioExpander { + public: + void setup() override; + + protected: + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override{}; +}; + +} // namespace esphome::gpio_expander_test_component diff --git a/tests/integration/fixtures/gpio_expander_cache.yaml b/tests/integration/fixtures/gpio_expander_cache.yaml new file mode 100644 index 0000000000..7d7ca1a876 --- /dev/null +++ b/tests/integration/fixtures/gpio_expander_cache.yaml @@ -0,0 +1,17 @@ +esphome: + name: gpio-expander-cache +host: + +logger: + level: DEBUG + +api: + +# External component that uses gpio_expander::CachedGpioExpander +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [gpio_expander_test_component] + +gpio_expander_test_component: diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py new file mode 100644 index 0000000000..9353bb1dd6 --- /dev/null +++ b/tests/integration/test_gpio_expander_cache.py @@ -0,0 +1,123 @@ +"""Integration test for CachedGPIOExpander to ensure correct behavior.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_gpio_expander_cache( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test gpio_expander::CachedGpioExpander correctly calls hardware functions.""" + # Get the path to the external components directory + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + logs_done = asyncio.Event() + + # Patterns to match in logs + digital_read_hw_pattern = re.compile(r"digital_read_hw pin=(\d+)") + digital_read_cache_pattern = re.compile(r"digital_read_cache pin=(\d+)") + + # ensure logs are in the expected order + log_order = [ + (digital_read_hw_pattern, 0), + [(digital_read_cache_pattern, i) for i in range(0, 8)], + (digital_read_hw_pattern, 8), + [(digital_read_cache_pattern, i) for i in range(8, 16)], + (digital_read_hw_pattern, 16), + [(digital_read_cache_pattern, i) for i in range(16, 24)], + (digital_read_hw_pattern, 24), + [(digital_read_cache_pattern, i) for i in range(24, 32)], + (digital_read_hw_pattern, 3), + (digital_read_cache_pattern, 3), + (digital_read_hw_pattern, 3), + (digital_read_cache_pattern, 3), + (digital_read_cache_pattern, 4), + (digital_read_hw_pattern, 3), + (digital_read_cache_pattern, 3), + (digital_read_hw_pattern, 10), + (digital_read_cache_pattern, 10), + # full cache reset here for testing + (digital_read_hw_pattern, 15), + (digital_read_cache_pattern, 15), + (digital_read_cache_pattern, 14), + (digital_read_hw_pattern, 14), + (digital_read_cache_pattern, 14), + ] + # Flatten the log order for easier processing + log_order: list[tuple[re.Pattern, int]] = [ + item + for sublist in log_order + for item in (sublist if isinstance(sublist, list) else [sublist]) + ] + + index = 0 + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + nonlocal index + if logs_done.is_set(): + return + + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "digital_read" in clean_line: + if index >= len(log_order): + print(f"Received unexpected log line: {clean_line}") + logs_done.set() + return + + pattern, expected_pin = log_order[index] + match = pattern.search(clean_line) + + if not match: + print(f"Log line did not match next expected pattern: {clean_line}") + logs_done.set() + return + + pin = int(match.group(1)) + if pin != expected_pin: + print(f"Unexpected pin number. Expected {expected_pin}, got {pin}") + logs_done.set() + return + + index += 1 + + elif "DONE" in clean_line: + # Check if we reached the end of the expected log entries + logs_done.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "gpio-expander-cache" + + try: + await asyncio.wait_for(logs_done.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for logs to complete") + + assert index == len(log_order), ( + f"Expected {len(log_order)} log entries, but got {index}" + ) From 5f9080dac97f5783c34be5e88b1b8d0cac290e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:57:29 -1000 Subject: [PATCH 20/21] fix --device OTA --- esphome/__main__.py | 126 ++++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 52 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6bb50864b1..61dee02e49 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -107,30 +107,50 @@ def choose_prompt(options, purpose: str = None): def choose_upload_log_host( - default, check_default, show_ota, show_mqtt, show_api, purpose: str = None -): + default: list[str] | str | None, + check_default: str | None, + show_ota: bool, + show_mqtt: bool, + show_api: bool, + purpose: str | None = None, +) -> list[str]: + # Convert to list for uniform handling + defaults = [default] if isinstance(default, str) else default or [] + + # If devices specified, resolve them + if defaults: + resolved: list[str] = [] + for device in defaults: + if device == "SERIAL": + options = [ + (f"{port.path} ({port.description})", port.path) + for port in get_serial_ports() + ] + resolved.append(choose_prompt(options, purpose=purpose)) + elif device == "OTA": + if (show_ota and "ota" in CORE.config) or ( + show_api and "api" in CORE.config + ): + resolved.append(CORE.address) + elif show_mqtt and has_mqtt_logging(): + resolved.append("MQTT") + else: + resolved.append(device) + return resolved + + # No devices specified, show interactive chooser options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if default == "SERIAL": - return choose_prompt(options, purpose=purpose) if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): options.append((f"Over The Air ({CORE.address})", CORE.address)) - if default == "OTA": - return CORE.address - if ( - show_mqtt - and (mqtt_config := CORE.config.get(CONF_MQTT)) - and mqtt_logging_enabled(mqtt_config) - ): + if show_mqtt and has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if default == "OTA": - return "MQTT" - if default is not None: - return default + 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) + return [check_default] + return [choose_prompt(options, purpose=purpose)] def mqtt_logging_enabled(mqtt_config): @@ -142,6 +162,13 @@ def mqtt_logging_enabled(mqtt_config): return log_topic.get(CONF_LEVEL, None) != "NONE" +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( + mqtt_config + ) + + def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" @@ -507,19 +534,18 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: - # No devices specified, use the interactive chooser - devices: list[str] = args.device or [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", - ) - ] + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( + default=args.device, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) # Try each device until one succeeds + exit_code = 1 for device in devices: _LOGGER.info("Uploading to %s", device) exit_code = upload_program(config, args, device) @@ -542,17 +568,15 @@ def command_discover(args: ArgsProtocol, config: ConfigType) -> int | None: def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: - # No devices specified, use the interactive chooser - devices = args.device or [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", - ) - ] + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( + default=args.device, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=True, + purpose="logging", + ) return show_logs(config, args, devices) @@ -573,17 +597,15 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: program_path = idedata.raw["prog_path"] return run_external_process(program_path) - # No devices specified, use the interactive chooser - devices = args.device or [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", - ) - ] + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( + default=args.device, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + purpose="uploading", + ) # Try each device for upload until one succeeds successful_device: str | None = None @@ -604,7 +626,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 # For logs, prefer the device we successfully uploaded to - port = choose_upload_log_host( + devices = choose_upload_log_host( default=successful_device, check_default=successful_device, show_ota=False, @@ -612,7 +634,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: show_api=True, purpose="logging", ) - return show_logs(config, args, [port]) + return show_logs(config, args, devices) def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: From 2fddb061e1211844ac13f900a3638076578827e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:51:42 -1000 Subject: [PATCH 21/21] Bump aioesphomeapi from 37.2.4 to 37.2.5 (#10080) 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 6f79e86bdf..134dcde822 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.4 +aioesphomeapi==37.2.5 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import