From 4ee32909290c27c856d564de8e437e67f6092aa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Apr 2025 08:19:16 +0200 Subject: [PATCH] Improve ESPHome dashboard diagnostics (#143914) --- .../components/esphome/diagnostics.py | 25 +++++- tests/components/esphome/common.py | 23 ++++++ .../esphome/snapshots/test_diagnostics.ambr | 81 ++++++++++++++++++- tests/components/esphome/test_dashboard.py | 23 +----- tests/components/esphome/test_diagnostics.py | 38 ++++++++- 5 files changed, 166 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 0903e874a15..c59fca26b90 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -10,10 +10,18 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from . import CONF_NOISE_PSK +from .const import CONF_DEVICE_NAME from .dashboard import async_get_dashboard from .entry_data import ESPHomeConfigEntry REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"} +CONFIGURED_DEVICE_KEYS = ( + "configuration", + "current_version", + "deployed_version", + "loaded_integrations", + "target_platform", +) async def async_get_config_entry_diagnostics( @@ -26,6 +34,9 @@ async def async_get_config_entry_diagnostics( entry_data = config_entry.runtime_data device_info = entry_data.device_info + device_name: str | None = ( + device_info.name if device_info else config_entry.data.get(CONF_DEVICE_NAME) + ) if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data @@ -45,7 +56,19 @@ async def async_get_config_entry_diagnostics( "scanner": await scanner.async_diagnostics(), } + diag_dashboard: dict[str, Any] = {"configured": False} + diag["dashboard"] = diag_dashboard if dashboard := async_get_dashboard(hass): - diag["dashboard"] = dashboard.addon_slug + diag_dashboard["configured"] = True + diag_dashboard["supports_update"] = dashboard.supports_update + diag_dashboard["last_update_success"] = dashboard.last_update_success + diag_dashboard["last_exception"] = dashboard.last_exception + diag_dashboard["addon"] = dashboard.addon_slug + if device_name and dashboard.data: + diag_dashboard["has_matching_name"] = device_name in dashboard.data + if data := dashboard.data.get(device_name): + diag_dashboard["device"] = { + key: data.get(key) for key in CONFIGURED_DEVICE_KEYS + } return async_redact_data(diag, REDACT_KEYS) diff --git a/tests/components/esphome/common.py b/tests/components/esphome/common.py index 39661c0f340..426eee11341 100644 --- a/tests/components/esphome/common.py +++ b/tests/components/esphome/common.py @@ -1,15 +1,38 @@ """ESPHome test common code.""" +from datetime import datetime + from homeassistant.components import assist_satellite from homeassistant.components.assist_satellite import AssistSatelliteEntity # pylint: disable-next=hass-component-root-import from homeassistant.components.esphome import DOMAIN from homeassistant.components.esphome.assist_satellite import EsphomeAssistSatellite +from homeassistant.components.esphome.coordinator import REFRESH_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +class MockDashboardRefresh: + """Mock dashboard refresh.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the mock dashboard refresh.""" + self.hass = hass + self.last_time: datetime | None = None + + async def async_refresh(self) -> None: + """Refresh the dashboard.""" + if self.last_time is None: + self.last_time = dt_util.utcnow() + self.last_time += REFRESH_INTERVAL + async_fire_time_changed(self.hass, self.last_time) + await self.hass.async_block_till_done() def get_satellite_entity( diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 8f1711e829e..d88f2045e56 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -26,6 +26,85 @@ 'unique_id': '11:22:33:44:55:aa', 'version': 1, }), - 'dashboard': 'mock-slug', + 'dashboard': dict({ + 'addon': 'mock-slug', + 'configured': True, + 'last_exception': None, + 'last_update_success': True, + 'supports_update': None, + }), + }) +# --- +# name: test_diagnostics_with_dashboard_data + dict({ + 'config': dict({ + 'data': dict({ + 'device_name': 'test', + 'host': 'test.local', + 'password': '', + 'port': 6053, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'esphome', + 'minor_version': 1, + 'options': dict({ + 'allow_service_calls': False, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': '11:22:33:44:55:aa', + 'version': 1, + }), + 'dashboard': dict({ + 'addon': 'mock-slug', + 'configured': True, + 'device': dict({ + 'configuration': 'test.yaml', + 'current_version': '2023.1.0', + 'deployed_version': None, + 'loaded_integrations': None, + 'target_platform': None, + }), + 'has_matching_name': True, + 'last_exception': None, + 'last_update_success': True, + 'supports_update': False, + }), + 'storage_data': dict({ + 'api_version': dict({ + 'major': 99, + 'minor': 99, + }), + 'device_info': dict({ + 'bluetooth_mac_address': '', + 'bluetooth_proxy_feature_flags': 0, + 'compilation_time': '', + 'esphome_version': '1.0.0', + 'friendly_name': 'Test', + 'has_deep_sleep': False, + 'legacy_bluetooth_proxy_version': 0, + 'legacy_voice_assistant_version': 0, + 'mac_address': '**REDACTED**', + 'manufacturer': '', + 'model': '', + 'name': 'test', + 'project_name': '', + 'project_version': '', + 'suggested_area': '', + 'uses_password': False, + 'voice_assistant_feature_flags': 0, + 'webserver_port': 0, + }), + 'services': list([ + ]), + 'update': list([ + ]), + }), }) # --- diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 99bdd5b5f47..340a10a86d1 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -1,6 +1,5 @@ """Test ESPHome dashboard features.""" -from datetime import datetime from typing import Any from unittest.mock import patch @@ -8,34 +7,16 @@ from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError import pytest from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard -from homeassistant.components.esphome.coordinator import REFRESH_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import VALID_NOISE_PSK +from .common import MockDashboardRefresh from .conftest import MockESPHomeDeviceType -from tests.common import MockConfigEntry, async_fire_time_changed - - -class MockDashboardRefresh: - """Mock dashboard refresh.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the mock dashboard refresh.""" - self.hass = hass - self.last_time: datetime | None = None - - async def async_refresh(self) -> None: - """Refresh the dashboard.""" - if self.last_time is None: - self.last_time = dt_util.utcnow() - self.last_time += REFRESH_INTERVAL - async_fire_time_changed(self.hass, self.last_time) - await self.hass.async_block_till_done() +from tests.common import MockConfigEntry @pytest.mark.usefixtures("init_integration", "mock_dashboard") diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2d64170bc97..250cc8dbc49 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import ANY +from aioesphomeapi import APIClient import pytest from syrupy import SnapshotAssertion from syrupy.filters import props @@ -10,7 +11,8 @@ from syrupy.filters import props from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .common import MockDashboardRefresh +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -31,6 +33,37 @@ async def test_diagnostics( assert result == snapshot(exclude=props("created_at", "modified_at")) +@pytest.mark.usefixtures("enable_bluetooth") +async def test_diagnostics_with_dashboard_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry with dashboard data.""" + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + "current_version": "2023.1.0", + } + ) + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await MockDashboardRefresh(hass).async_refresh() + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_device.entry + ) + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) + + async def test_diagnostics_with_bluetooth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -43,6 +76,9 @@ async def test_diagnostics_with_bluetooth( entry = mock_bluetooth_entry_with_raw_adv.entry result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { + "dashboard": { + "configured": False, + }, "bluetooth": { "available": True, "connections_free": 0,