1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-09 06:42:20 +01:00

Support multiple --device arguments for address fallback

This commit is contained in:
J. Nick Koston
2025-07-31 17:01:34 -10:00
parent 28b277c1c4
commit 1c67dfc850
3 changed files with 122 additions and 55 deletions

View File

@@ -356,7 +356,7 @@ def upload_program(config, args, host):
return upload_using_esptool(config, host, file, args.upload_speed) return upload_using_esptool(config, host, file, args.upload_speed)
if CORE.target_platform in (PLATFORM_RP2040): if CORE.target_platform in (PLATFORM_RP2040):
return upload_using_platformio(config, args.device) return upload_using_platformio(config, host)
if CORE.is_libretiny: if CORE.is_libretiny:
return upload_using_platformio(config, host) return upload_using_platformio(config, host)
@@ -379,9 +379,12 @@ def upload_program(config, args, host):
remote_port = int(ota_conf[CONF_PORT]) remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD, "") 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 ( if (
CONF_MQTT in config # pylint: disable=too-many-boolean-expressions 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 ( and (
((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address))
or get_port_type(host) == "MQTT" 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) 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: if "logger" not in config:
raise EsphomeError("Logger is not configured!") raise EsphomeError("Logger is not configured!")
port = devices[0]
if get_port_type(port) == "SERIAL": if get_port_type(port) == "SERIAL":
check_permissions(port) check_permissions(port)
return run_miniterm(config, port, args) return run_miniterm(config, port, args)
if get_port_type(port) == "NETWORK" and "api" in config: 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: if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config:
from esphome import mqtt 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 config, args.username, args.password, args.client_id
)[0] )[0]
addresses_to_use = [mqtt_address]
from esphome.components.api.client import run_logs 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: if get_port_type(port) == "MQTT" and "mqtt" in config:
from esphome import mqtt from esphome import mqtt
@@ -478,19 +486,31 @@ def command_compile(args, config):
def command_upload(args, config): def command_upload(args, config):
port = choose_upload_log_host( devices = args.device or []
default=args.device, if not devices:
check_default=None, # No devices specified, use the interactive chooser
show_ota=True, devices = [
show_mqtt=False, choose_upload_log_host(
show_api=False, default=None,
purpose="uploading", check_default=None,
) show_ota=True,
exit_code = upload_program(config, args, port) show_mqtt=False,
if exit_code != 0: show_api=False,
return exit_code purpose="uploading",
_LOGGER.info("Successfully uploaded program.") )
return 0 ]
# 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): def command_discover(args, config):
@@ -503,15 +523,21 @@ def command_discover(args, config):
def command_logs(args, config): def command_logs(args, config):
port = choose_upload_log_host( devices = args.device or []
default=args.device, if not devices:
check_default=None, # No devices specified, use the interactive chooser
show_ota=False, devices = [
show_mqtt=True, choose_upload_log_host(
show_api=True, default=None,
purpose="logging", check_default=None,
) show_ota=False,
return show_logs(config, args, port) show_mqtt=True,
show_api=True,
purpose="logging",
)
]
return show_logs(config, args, devices)
def command_run(args, config): def command_run(args, config):
@@ -531,29 +557,48 @@ def command_run(args, config):
program_path = idedata.raw["prog_path"] program_path = idedata.raw["prog_path"]
return run_external_process(program_path) return run_external_process(program_path)
port = choose_upload_log_host( devices = args.device or []
default=args.device, if not devices:
check_default=None, # No devices specified, use the interactive chooser
show_ota=True, devices = [
show_mqtt=False, choose_upload_log_host(
show_api=True, default=None,
purpose="uploading", check_default=None,
) show_ota=True,
exit_code = upload_program(config, args, port) show_mqtt=False,
if exit_code != 0: 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 return exit_code
_LOGGER.info("Successfully uploaded program.")
if args.no_logs: if args.no_logs:
return 0 return 0
# For logs, prefer the device we successfully uploaded to
port = choose_upload_log_host( port = choose_upload_log_host(
default=args.device, default=successful_device,
check_default=port, check_default=successful_device,
show_ota=False, show_ota=False,
show_mqtt=True, show_mqtt=True,
show_api=True, show_api=True,
purpose="logging", purpose="logging",
) )
return show_logs(config, args, port) return show_logs(config, args, [port])
def command_clean_mqtt(args, config): def command_clean_mqtt(args, config):
@@ -854,7 +899,8 @@ def parse_args(argv):
) )
parser_upload.add_argument( parser_upload.add_argument(
"--device", "--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( parser_upload.add_argument(
"--upload_speed", "--upload_speed",
@@ -876,7 +922,8 @@ def parse_args(argv):
) )
parser_logs.add_argument( parser_logs.add_argument(
"--device", "--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( parser_logs.add_argument(
"--reset", "--reset",
@@ -905,7 +952,8 @@ def parse_args(argv):
) )
parser_run.add_argument( parser_run.add_argument(
"--device", "--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( parser_run.add_argument(
"--upload_speed", "--upload_speed",

View File

@@ -30,7 +30,7 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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.""" """Run the logs command in the event loop."""
conf = config["api"] conf = config["api"]
name = config["esphome"]["name"] 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 noise_psk: str | None = None
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
noise_psk = 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( cli = APIClient(
address, addresses[0], # Primary address for compatibility
port, port,
password, password,
client_info=f"ESPHome Logs {__version__}", client_info=f"ESPHome Logs {__version__}",
noise_psk=noise_psk, noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
) )
dashboard = CORE.dashboard dashboard = CORE.dashboard
@@ -66,7 +74,7 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None:
await stop() 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.""" """Run the logs command."""
with contextlib.suppress(KeyboardInterrupt): with contextlib.suppress(KeyboardInterrupt):
asyncio.run(async_run_logs(config, address)) asyncio.run(async_run_logs(config, addresses))

View File

@@ -324,6 +324,8 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
configuration = json_message["configuration"] configuration = json_message["configuration"]
config_file = settings.rel_path(configuration) config_file = settings.rel_path(configuration)
port = json_message["port"] port = json_message["port"]
addresses: list[str] = []
if ( if (
port == "OTA" # pylint: disable=too-many-boolean-expressions port == "OTA" # pylint: disable=too-many-boolean-expressions
and (entry := entries.get(config_file)) and (entry := entries.get(config_file))
@@ -333,10 +335,10 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
if (mdns := dashboard.mdns_status) and ( if (mdns := dashboard.mdns_status) and (
address_list := await mdns.async_resolve_host(entry.name) 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 # if the API is loaded and the device is online
# since MQTT logging will not work otherwise # since MQTT logging will not work otherwise
port = sort_ip_addresses(address_list)[0] addresses = sort_ip_addresses(address_list)
elif ( elif (
entry.address entry.address
and ( and (
@@ -347,16 +349,25 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
and not isinstance(address_list, Exception) and not isinstance(address_list, Exception)
): ):
# If mdns is not available, try to use the DNS cache # 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, *DASHBOARD_COMMAND,
*args, *args,
config_file, 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): class EsphomeLogsHandler(EsphomePortCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]: async def build_command(self, json_message: dict[str, Any]) -> list[str]: