diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py new file mode 100644 index 00000000000..284e17fd183 --- /dev/null +++ b/homeassistant/components/esphome/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator to interact with an ESPHome dashboard.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import aiohttp +from awesomeversion import AwesomeVersion +from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") + + +class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): + """Class to interact with the ESPHome dashboard.""" + + def __init__( + self, + hass: HomeAssistant, + addon_slug: str, + url: str, + session: aiohttp.ClientSession, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name="ESPHome Dashboard", + update_interval=timedelta(minutes=5), + always_update=False, + ) + self.addon_slug = addon_slug + self.url = url + self.api = ESPHomeDashboardAPI(url, session) + self.supports_update: bool | None = None + + async def _async_update_data(self) -> dict: + """Fetch device data.""" + devices = await self.api.get_devices() + configured_devices = devices["configured"] + + if ( + self.supports_update is None + and configured_devices + and (current_version := configured_devices[0].get("current_version")) + ): + self.supports_update = ( + AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE + ) + + return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 54a593fe0cc..b2d0487df9c 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -1,25 +1,20 @@ -"""Files to interact with a the ESPHome dashboard.""" +"""Files to interact with an ESPHome dashboard.""" from __future__ import annotations import asyncio -from datetime import timedelta import logging from typing import Any -import aiohttp -from awesomeversion import AwesomeVersion -from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI - from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import ESPHomeDashboardCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,8 +24,6 @@ KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 -MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") - async def async_setup(hass: HomeAssistant) -> None: """Set up the ESPHome dashboard.""" @@ -58,7 +51,7 @@ class ESPHomeDashboardManager: self._hass = hass self._store: Store[dict[str, Any]] = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._data: dict[str, Any] | None = None - self._current_dashboard: ESPHomeDashboard | None = None + self._current_dashboard: ESPHomeDashboardCoordinator | None = None self._cancel_shutdown: CALLBACK_TYPE | None = None async def async_setup(self) -> None: @@ -70,7 +63,7 @@ class ESPHomeDashboardManager: ) @callback - def async_get(self) -> ESPHomeDashboard | None: + def async_get(self) -> ESPHomeDashboardCoordinator | None: """Get the current dashboard.""" return self._current_dashboard @@ -92,7 +85,7 @@ class ESPHomeDashboardManager: self._cancel_shutdown = None self._current_dashboard = None - dashboard = ESPHomeDashboard( + dashboard = ESPHomeDashboardCoordinator( hass, addon_slug, url, async_get_clientsession(hass) ) await dashboard.async_request_refresh() @@ -138,7 +131,7 @@ class ESPHomeDashboardManager: @callback -def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: +def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboardCoordinator | None: """Get an instance of the dashboard if set. This is only safe to call after `async_setup` has been completed. @@ -157,43 +150,3 @@ async def async_set_dashboard_info( """Set the dashboard info.""" manager = await async_get_or_create_dashboard_manager(hass) await manager.async_set_dashboard_info(addon_slug, host, port) - - -class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): # pylint: disable=hass-enforce-coordinator-module - """Class to interact with the ESPHome dashboard.""" - - def __init__( - self, - hass: HomeAssistant, - addon_slug: str, - url: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - name="ESPHome Dashboard", - update_interval=timedelta(minutes=5), - always_update=False, - ) - self.addon_slug = addon_slug - self.url = url - self.api = ESPHomeDashboardAPI(url, session) - self.supports_update: bool | None = None - - async def _async_update_data(self) -> dict: - """Fetch device data.""" - devices = await self.api.get_devices() - configured_devices = devices["configured"] - - if ( - self.supports_update is None - and configured_devices - and (current_version := configured_devices[0].get("current_version")) - ): - self.supports_update = ( - AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE - ) - - return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index b16a6e798b7..cbcb3ae1c70 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -20,7 +20,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .dashboard import ESPHomeDashboard, async_get_dashboard +from .coordinator import ESPHomeDashboardCoordinator +from .dashboard import async_get_dashboard from .domain_data import DomainData from .entry_data import RuntimeEntryData @@ -65,7 +66,7 @@ async def async_setup_entry( ] -class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): +class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboardCoordinator], UpdateEntity): """Defines an ESPHome update entity.""" _attr_has_entity_name = True @@ -75,7 +76,7 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_release_url = "https://esphome.io/changelog/" def __init__( - self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard + self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboardCoordinator ) -> None: """Initialize the update entity.""" super().__init__(coordinator=coordinator) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 1142d2b0411..c5052220313 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -338,7 +338,7 @@ async def test_user_dashboard_has_wrong_key( ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=WRONG_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( @@ -393,7 +393,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( @@ -446,7 +446,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", side_effect=dashboard_exception, ): result = await hass.config_entries.flow.async_init( @@ -859,7 +859,7 @@ async def test_reauth_fixed_via_dashboard( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_init( @@ -902,7 +902,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_init( @@ -990,7 +990,7 @@ async def test_reauth_fixed_via_dashboard_at_confirm( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: # We just fetch the form @@ -1211,7 +1211,7 @@ async def test_zeroconf_encryption_key_via_dashboard( ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_configure( @@ -1277,7 +1277,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 01c1553cf42..dbf092bb9fc 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -4,7 +4,7 @@ from unittest.mock import patch from aioesphomeapi import DeviceInfo, InvalidAuthAPIError -from homeassistant.components.esphome import CONF_NOISE_PSK, dashboard +from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -56,7 +56,7 @@ async def test_restore_dashboard_storage_end_to_end( "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, } with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI" + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" ) as mock_dashboard_api: await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -69,7 +69,7 @@ async def test_setup_dashboard_fails( ) -> MockConfigEntry: """Test that nothing is stored on failed dashboard setup when there was no dashboard before.""" with patch.object( - dashboard.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError ) as mock_get_devices: await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -86,7 +86,9 @@ async def test_setup_dashboard_fails_when_already_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage ) -> MockConfigEntry: """Test failed dashboard setup still reloads entries if one existed before.""" - with patch.object(dashboard.ESPHomeDashboardAPI, "get_devices") as mock_get_devices: + with patch.object( + coordinator.ESPHomeDashboardAPI, "get_devices" + ) as mock_get_devices: await dashboard.async_set_dashboard_info( hass, "test-slug", "working-host", 6052 ) @@ -100,7 +102,7 @@ async def test_setup_dashboard_fails_when_already_setup( with ( patch.object( - dashboard.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError ) as mock_get_devices, patch( "homeassistant.components.esphome.async_setup_entry", return_value=True @@ -145,7 +147,7 @@ async def test_new_dashboard_fix_reauth( ) with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_init( @@ -171,7 +173,7 @@ async def test_new_dashboard_fix_reauth( with ( patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key, patch(