From 921758f87dcdaf12cb40d12f1c5443094b5fc4e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:55 -0500 Subject: [PATCH] [core] Clarify resolve error when a device has no network log/OTA transport (#17107) --- esphome/__main__.py | 39 +++++++++++++++++++----- tests/unit_tests/test_main.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05..680de02201 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -268,6 +268,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: return _resolve_with_cache(CORE.address, purpose) +def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: + """Build the error when a default device target produced no usable host. + + When the OTA default was requested and the address resolves but the config + lacks the transport the purpose needs (``api:`` for logs, an ``ota:`` + platform for uploads), name that gap instead of the misleading + "could not be resolved" / set-use_address hint. + """ + if "OTA" in defaults and has_resolvable_address(): + if purpose == Purpose.LOGGING and not has_api(): + return ( + "Cannot view logs over the network: no 'api:' component is " + "configured. Network log streaming requires the native API; add " + "an 'api:' component, enable MQTT logging, or view logs over USB." + ) + if purpose == Purpose.UPLOADING and not has_ota(): + return ( + "Cannot upload over the network: no 'ota:' platform is " + "configured. Add an 'ota:' platform, or upload over USB." + ) + if CORE.dashboard: + hint = "If you know the IP, set 'use_address' in your network config." + else: + hint = "If you know the IP, try --device " + return ( + f"All specified devices {defaults} could not be resolved. " + f"Is the device connected to the network? {hint}" + ) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -317,14 +347,7 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - if CORE.dashboard: - hint = "If you know the IP, set 'use_address' in your network config." - else: - hint = "If you know the IP, try --device " - raise EsphomeError( - f"All specified devices {defaults} could not be resolved. " - f"Is the device connected to the network? {hint}" - ) + raise EsphomeError(_unresolved_default_error(purpose, defaults)) return resolved # No devices specified, show interactive chooser diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc6..bb06b6c930 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, @@ -713,9 +714,7 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") - with pytest.raises( - EsphomeError, match="All specified devices .* could not be resolved" - ): + with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"): choose_upload_log_host( default="OTA", check_default=None, @@ -735,6 +734,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: + """A resolvable device with only ota: fails logs with a missing-api message.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None: + """A resolvable device with neither api: nor MQTT logging fails clearly.""" + setup_core(address="192.168.1.100") + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: + """A .local host with mDNS disabled and no cache keeps the dashboard hint.""" + setup_core( + config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}}, + address="esp32-a1s.local", + ) + CORE.dashboard = True + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "could not be resolved" in msg + assert "set 'use_address'" in msg + + +def test_unresolved_default_error_upload_with_ota_is_generic() -> None: + """With ota: present the upload error stays generic, not transport-specific.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + CORE.dashboard = False + + msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"]) + assert "could not be resolved" in msg + assert "try --device " in msg + + @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config."""