From 29337bc6ebb5a4649c3e14636c2374007182afe0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Jan 2023 11:59:55 -0500 Subject: [PATCH] Reload ESPHome config entries when dashboard info received (#86174) --- homeassistant/components/esphome/__init__.py | 3 +- .../components/esphome/config_flow.py | 5 +- homeassistant/components/esphome/const.py | 3 + homeassistant/components/esphome/dashboard.py | 59 +++++++++-------- .../components/esphome/domain_data.py | 2 +- homeassistant/components/esphome/update.py | 1 - tests/components/esphome/conftest.py | 11 ++++ tests/components/esphome/test_config_flow.py | 63 +++++++++---------- tests/components/esphome/test_dashboard.py | 22 +++++++ tests/components/esphome/test_update.py | 7 +-- 10 files changed, 106 insertions(+), 70 deletions(-) create mode 100644 homeassistant/components/esphome/const.py create mode 100644 tests/components/esphome/test_dashboard.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 73009399ab2..4fabccb2892 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -56,8 +56,9 @@ from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from .bluetooth import async_connect_scanner +from .const import DOMAIN from .dashboard import async_get_dashboard -from .domain_data import DOMAIN, DomainData +from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 4b6e8ccb9ab..ee8da40d0ba 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -26,7 +26,8 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac -from . import CONF_DEVICE_NAME, CONF_NOISE_PSK, DOMAIN +from . import CONF_DEVICE_NAME, CONF_NOISE_PSK +from .const import DOMAIN from .dashboard import async_get_dashboard, async_set_dashboard_info ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" @@ -204,7 +205,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle Supervisor service discovery.""" - async_set_dashboard_info( + await async_set_dashboard_info( self.hass, discovery_info.slug, discovery_info.config["host"], diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py new file mode 100644 index 00000000000..617c817924b --- /dev/null +++ b/homeassistant/components/esphome/const.py @@ -0,0 +1,3 @@ +"""ESPHome constants.""" + +DOMAIN = "esphome" diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 336480577d7..3ce07d683b9 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -8,9 +8,12 @@ import logging import aiohttp from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN KEY_DASHBOARD = "esphome_dashboard" @@ -21,23 +24,41 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: return hass.data.get(KEY_DASHBOARD) -def async_set_dashboard_info( +async def async_set_dashboard_info( hass: HomeAssistant, addon_slug: str, host: str, port: int ) -> None: """Set the dashboard info.""" - hass.data[KEY_DASHBOARD] = ESPHomeDashboard( - hass, - addon_slug, - f"http://{host}:{port}", - async_get_clientsession(hass), - ) + url = f"http://{host}:{port}" + + # Do nothing if we already have this data. + if ( + (cur_dashboard := hass.data.get(KEY_DASHBOARD)) + and cur_dashboard.addon_slug == addon_slug + and cur_dashboard.url == url + ): + return + + dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) + try: + await dashboard.async_request_refresh() + except UpdateFailed as err: + logging.getLogger(__name__).error("Ignoring dashboard info: %s", err) + return + + hass.data[KEY_DASHBOARD] = dashboard + + reloads = [ + hass.config_entries.async_reload(entry.entry_id) + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if reloads: + await asyncio.gather(*reloads) class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): """Class to interact with the ESPHome dashboard.""" - _first_fetch_lock: asyncio.Lock | None = None - def __init__( self, hass: HomeAssistant, @@ -53,25 +74,9 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): update_interval=timedelta(minutes=5), ) self.addon_slug = addon_slug + self.url = url self.api = ESPHomeDashboardAPI(url, session) - async def ensure_data(self) -> None: - """Ensure the update coordinator has data when this call finishes.""" - if self.data: - return - - if self._first_fetch_lock is not None: - async with self._first_fetch_lock: - # We know the data is fetched when lock is done - return - - self._first_fetch_lock = asyncio.Lock() - - async with self._first_fetch_lock: - await self.async_request_refresh() - - self._first_fetch_lock = None - async def _async_update_data(self) -> dict: """Fetch device data.""" devices = await self.api.get_devices() diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 93ff69852a0..07029e2610a 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import Store +from .const import DOMAIN from .entry_data import RuntimeEntryData STORAGE_VERSION = 1 -DOMAIN = "esphome" MAX_CACHED_SERVICES = 128 _DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData") diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 55b7931a294..aae2ab46f04 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -49,7 +49,6 @@ async def async_setup_entry( unsub() # type: ignore[unreachable] assert dashboard is not None - await dashboard.ensure_data() async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)]) if entry_data.available: diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 44915befdcb..4dcd88538c0 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -98,3 +98,14 @@ def mock_client(mock_device_info): "homeassistant.components.esphome.config_flow.APIClient", mock_client ): yield mock_client + + +@pytest.fixture +def mock_dashboard(): + """Mock dashboard.""" + data = {"configured": [], "importable": []} + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + return_value=data, + ): + yield data diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 9c49fe0f3f2..a237f80d650 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -471,9 +471,10 @@ async def test_reauth_confirm_valid(hass, mock_client, mock_zeroconf): assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -async def test_reauth_fixed_via_dashboard(hass, mock_client, mock_zeroconf): +async def test_reauth_fixed_via_dashboard( + hass, mock_client, mock_zeroconf, mock_dashboard +): """Test reauth fixed automatically via dashboard.""" - dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052) entry = MockConfigEntry( domain=DOMAIN, @@ -488,17 +489,16 @@ async def test_reauth_fixed_via_dashboard(hass, mock_client, mock_zeroconf): mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + } + ) + + await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052) + with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices", - return_value={ - "configured": [ - { - "name": "test", - "configuration": "test.yaml", - } - ] - }, - ), patch( "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: @@ -511,7 +511,7 @@ async def test_reauth_fixed_via_dashboard(hass, mock_client, mock_zeroconf): }, ) - assert result["type"] == FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT, result assert result["reason"] == "reauth_successful" assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK @@ -672,7 +672,9 @@ async def test_discovery_hassio(hass): assert dash.addon_slug == "mock-slug" -async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zeroconf): +async def test_zeroconf_encryption_key_via_dashboard( + hass, mock_client, mock_zeroconf, mock_dashboard +): """Test encryption key retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( host="192.168.43.183", @@ -692,7 +694,14 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer assert flow["type"] == FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" - dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052) + mock_dashboard["configured"].append( + { + "name": "test8266", + "configuration": "test8266.yaml", + } + ) + + await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052) mock_client.device_info.side_effect = [ RequiresEncryptionAPIError, @@ -704,16 +713,6 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices", - return_value={ - "configured": [ - { - "name": "test8266", - "configuration": "test8266.yaml", - } - ] - }, - ), patch( "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: @@ -736,7 +735,7 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer async def test_zeroconf_no_encryption_key_via_dashboard( - hass, mock_client, mock_zeroconf + hass, mock_client, mock_zeroconf, mock_dashboard ): """Test encryption key not retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -757,17 +756,13 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert flow["type"] == FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" - dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052) + await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052) mock_client.device_info.side_effect = RequiresEncryptionAPIError - with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices", - return_value={"configured": []}, - ): - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "encryption_key" diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py new file mode 100644 index 00000000000..7a5486d5205 --- /dev/null +++ b/tests/components/esphome/test_dashboard.py @@ -0,0 +1,22 @@ +"""Test ESPHome dashboard features.""" +from unittest.mock import patch + +from homeassistant.components.esphome import dashboard +from homeassistant.config_entries import ConfigEntryState + + +async def test_new_info_reload_config_entries(hass, init_integration, mock_dashboard): + """Test config entries are reloaded when new info is set.""" + assert init_integration.state == ConfigEntryState.LOADED + + with patch("homeassistant.components.esphome.async_setup_entry") as mock_setup: + await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) + + assert len(mock_setup.mock_calls) == 1 + assert mock_setup.mock_calls[0][1][1] == init_integration + + # Test it's a no-op when the same info is set + with patch("homeassistant.components.esphome.async_setup_entry") as mock_setup: + await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) + + assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 3a01245de41..aa379cfbec5 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -37,21 +37,20 @@ async def test_update_entity( hass, mock_config_entry, mock_device_info, + mock_dashboard, devices_payload, expected_state, expected_attributes, ): """Test ESPHome update entity.""" - async_set_dashboard_info(hass, "mock-addon-slug", "mock-addon-host", 1234) + mock_dashboard["configured"] = devices_payload + await async_set_dashboard_info(hass, "mock-addon-slug", "mock-addon-host", 1234) mock_config_entry.add_to_hass(hass) with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=Mock(available=True, device_info=mock_device_info), - ), patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices", - return_value={"configured": devices_payload}, ): assert await hass.config_entries.async_forward_entry_setup( mock_config_entry, "update"