From 60b3d6816b0a24a3a5043ff09cb311dba0b8a436 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 20 Oct 2022 19:37:20 -0600 Subject: [PATCH] Replace custom OpenUV data object with coordinators (#80705) * Replace custom OpenUV data object with coordinators * Typing * Code review --- homeassistant/components/openuv/__init__.py | 227 ++++++------------ .../components/openuv/binary_sensor.py | 30 +-- .../components/openuv/coordinator.py | 55 +++++ .../components/openuv/diagnostics.py | 18 +- homeassistant/components/openuv/sensor.py | 25 +- tests/components/openuv/test_diagnostics.py | 7 - 6 files changed, 168 insertions(+), 194 deletions(-) create mode 100644 homeassistant/components/openuv/coordinator.py diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 365a3ab247a..1e85e70100f 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from typing import Any from pyopenuv import Client -from pyopenuv.errors import OpenUvError import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -20,20 +18,16 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( aiohttp_client, config_validation as cv, entity_registry, ) -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed from .const import ( CONF_FROM_WINDOW, @@ -45,13 +39,10 @@ from .const import ( DOMAIN, LOGGER, ) +from .coordinator import OpenUvCoordinator CONF_ENTRY_ID = "entry_id" -DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 - -TOPIC_UPDATE = f"{DOMAIN}_data_update" - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] SERVICE_NAME_UPDATE_DATA = "update_data" @@ -127,53 +118,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) - openuv = OpenUV( - hass, - entry, - Client( - entry.data[CONF_API_KEY], - entry.data.get(CONF_LATITUDE, hass.config.latitude), - entry.data.get(CONF_LONGITUDE, hass.config.longitude), - altitude=entry.data.get(CONF_ELEVATION, hass.config.elevation), - session=websession, - ), + client = Client( + entry.data[CONF_API_KEY], + entry.data.get(CONF_LATITUDE, hass.config.latitude), + entry.data.get(CONF_LONGITUDE, hass.config.longitude), + altitude=entry.data.get(CONF_ELEVATION, hass.config.elevation), + session=websession, ) + async def async_update_protection_data() -> dict[str, Any]: + """Update binary sensor (protection window) data.""" + low = entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) + high = entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) + return await client.uv_protection_window(low=low, high=high) + + coordinators: dict[str, OpenUvCoordinator] = { + coordinator_name: OpenUvCoordinator( + hass, + name=coordinator_name, + latitude=client.latitude, + longitude=client.longitude, + update_method=update_method, + ) + for coordinator_name, update_method in ( + (DATA_UV, client.uv_index), + (DATA_PROTECTION_WINDOW, async_update_protection_data), + ) + } + # We disable the client's request retry abilities here to avoid a lengthy (and - # blocking) startup: - openuv.client.disable_request_retries() - - try: - await openuv.async_update() - except HomeAssistantError as err: - LOGGER.error("Config entry failed: %s", err) - raise ConfigEntryNotReady from err - - # Once we've successfully authenticated, we re-enable client request retries: - openuv.client.enable_request_retries() + # blocking) startup; then, if the initial update is successful, we re-enable client + # request retries: + client.disable_request_retries() + init_tasks = [ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values() + ] + await asyncio.gather(*init_tasks) + client.enable_request_retries() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = openuv + hass.data[DOMAIN][entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def extract_openuv(func: Callable) -> Callable: - """Define a decorator to get the correct OpenUV object for a service call.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - openuv: OpenUV = hass.data[DOMAIN][call.data[CONF_ENTRY_ID]] - - try: - await func(call, openuv) - except OpenUvError as err: - raise HomeAssistantError( - f'Error while executing "{call.service}": {err}' - ) from err - - return wrapper - # We determine entity IDs needed to help the user migrate from deprecated services: current_uv_index_entity_id = async_get_entity_id_from_unique_id_suffix( hass, entry, "current_uv_index" @@ -183,8 +171,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @_verify_domain_control - @extract_openuv - async def update_data(call: ServiceCall, openuv: OpenUV) -> None: + async def update_data(call: ServiceCall) -> None: """Refresh all OpenUV data.""" LOGGER.debug("Refreshing all OpenUV data") async_log_deprecated_service_call( @@ -194,12 +181,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [protection_window_entity_id, current_uv_index_entity_id], "2022.12.0", ) - await openuv.async_update() - async_dispatcher_send(hass, TOPIC_UPDATE) + + tasks = [coordinator.async_refresh() for coordinator in coordinators.values()] + try: + await asyncio.gather(*tasks) + except UpdateFailed as err: + raise HomeAssistantError(err) from err @_verify_domain_control - @extract_openuv - async def update_uv_index_data(call: ServiceCall, openuv: OpenUV) -> None: + async def update_uv_index_data(call: ServiceCall) -> None: """Refresh OpenUV UV index data.""" LOGGER.debug("Refreshing OpenUV UV index data") async_log_deprecated_service_call( @@ -209,12 +199,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [current_uv_index_entity_id], "2022.12.0", ) - await openuv.async_update_uv_index_data() - async_dispatcher_send(hass, TOPIC_UPDATE) + + try: + await coordinators[DATA_UV].async_request_refresh() + except UpdateFailed as err: + raise HomeAssistantError(err) from err @_verify_domain_control - @extract_openuv - async def update_protection_data(call: ServiceCall, openuv: OpenUV) -> None: + async def update_protection_data(call: ServiceCall) -> None: """Refresh OpenUV protection window data.""" LOGGER.debug("Refreshing OpenUV protection window data") async_log_deprecated_service_call( @@ -224,8 +216,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [protection_window_entity_id], "2022.12.0", ) - await openuv.async_update_protection_data() - async_dispatcher_send(hass, TOPIC_UPDATE) + + try: + await coordinators[DATA_PROTECTION_WINDOW].async_request_refresh() + except UpdateFailed as err: + raise HomeAssistantError(err) from err service_schema = vol.Schema( { @@ -283,106 +278,42 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -class OpenUV: - """Define a generic OpenUV object.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: Client) -> None: - """Initialize.""" - self._update_protection_data_debouncer = Debouncer( - hass, - LOGGER, - cooldown=DEFAULT_DEBOUNCER_COOLDOWN_SECONDS, - immediate=True, - function=self._async_update_protection_data, - ) - - self._update_uv_index_data_debouncer = Debouncer( - hass, - LOGGER, - cooldown=DEFAULT_DEBOUNCER_COOLDOWN_SECONDS, - immediate=True, - function=self._async_update_uv_index_data, - ) - - self._entry = entry - self.client = client - self.data: dict[str, Any] = {DATA_PROTECTION_WINDOW: {}, DATA_UV: {}} - - async def _async_update_protection_data(self) -> None: - """Update binary sensor (protection window) data.""" - low = self._entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) - high = self._entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) - - try: - data = await self.client.uv_protection_window(low=low, high=high) - except OpenUvError as err: - raise HomeAssistantError( - f"Error during protection data update: {err}" - ) from err - - self.data[DATA_PROTECTION_WINDOW] = data.get("result") - - async def _async_update_uv_index_data(self) -> None: - """Update sensor (uv index, etc) data.""" - try: - data = await self.client.uv_index() - except OpenUvError as err: - raise HomeAssistantError( - f"Error during UV index data update: {err}" - ) from err - - self.data[DATA_UV] = data.get("result") - - async def async_update_protection_data(self) -> None: - """Update binary sensor (protection window) data with a debouncer.""" - await self._update_protection_data_debouncer.async_call() - - async def async_update_uv_index_data(self) -> None: - """Update sensor (uv index, etc) data with a debouncer.""" - await self._update_uv_index_data_debouncer.async_call() - - async def async_update(self) -> None: - """Update sensor/binary sensor data.""" - tasks = [self.async_update_protection_data(), self.async_update_uv_index_data()] - await asyncio.gather(*tasks) - - -class OpenUvEntity(Entity): +class OpenUvEntity(CoordinatorEntity): """Define a generic OpenUV entity.""" _attr_has_entity_name = True - def __init__(self, openuv: OpenUV, description: EntityDescription) -> None: + def __init__( + self, coordinator: OpenUvCoordinator, description: EntityDescription + ) -> None: """Initialize.""" + super().__init__(coordinator) + self._attr_extra_state_attributes = {} - self._attr_should_poll = False self._attr_unique_id = ( - f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}" + f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" ) self.entity_description = description - self.openuv = openuv @callback - def async_update_state(self) -> None: - """Update the state.""" - self.update_from_latest_data() + def _handle_coordinator_update(self) -> None: + """Respond to a DataUpdateCoordinator update.""" + self._update_from_latest_data() self.async_write_ha_state() + @callback + def _update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError + async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.update_from_latest_data() - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_UPDATE, self.async_update_state) - ) + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._update_from_latest_data() async def async_update(self) -> None: """Update the entity. - Only used by the generic entity update service. Should be implemented by each - OpenUV platform. + Only used by the generic entity update service. """ - raise NotImplementedError - - def update_from_latest_data(self) -> None: - """Update the sensor using the latest data.""" - raise NotImplementedError + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index b1c962932b7..1e69af66eec 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.util.dt import as_local, parse_datetime, utcnow from . import OpenUvEntity from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW +from .coordinator import OpenUvCoordinator ATTR_PROTECTION_WINDOW_ENDING_TIME = "end_time" ATTR_PROTECTION_WINDOW_ENDING_UV = "end_uv" @@ -26,32 +27,27 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: + # Once we've successfully authenticated, we re-enable client request retries: """Set up an OpenUV sensor based on a config entry.""" - openuv = hass.data[DOMAIN][entry.entry_id] + coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + async_add_entities( - [OpenUvBinarySensor(openuv, BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW)] + [ + OpenUvBinarySensor( + coordinators[DATA_PROTECTION_WINDOW], + BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW, + ) + ] ) class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.openuv.async_update_protection_data() - self.async_update_state() - @callback - def update_from_latest_data(self) -> None: - """Update the state.""" - if not (data := self.openuv.data[DATA_PROTECTION_WINDOW]): - self._attr_available = False - return - - self._attr_available = True + def _update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + data = self.coordinator.data for key in ("from_time", "to_time", "from_uv", "to_uv"): if not data.get(key): diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py new file mode 100644 index 00000000000..993970658ef --- /dev/null +++ b/homeassistant/components/openuv/coordinator.py @@ -0,0 +1,55 @@ +"""Define an update coordinator for OpenUV.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from pyopenuv.errors import OpenUvError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 + + +class OpenUvCoordinator(DataUpdateCoordinator): + """Define an OpenUV data coordinator.""" + + update_method: Callable[[], Awaitable[dict[str, Any]]] + + def __init__( + self, + hass: HomeAssistant, + *, + name: str, + latitude: str, + longitude: str, + update_method: Callable[[], Awaitable[dict[str, Any]]], + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=name, + update_method=update_method, + request_refresh_debouncer=Debouncer( + hass, + LOGGER, + cooldown=DEFAULT_DEBOUNCER_COOLDOWN_SECONDS, + immediate=True, + ), + ) + + self.latitude = latitude + self.longitude = longitude + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from OpenUV.""" + try: + data = await self.update_method() + except OpenUvError as err: + raise UpdateFailed(f"Error during protection data update: {err}") from err + return cast(dict[str, Any], data["result"]) diff --git a/homeassistant/components/openuv/diagnostics.py b/homeassistant/components/openuv/diagnostics.py index 30443dd90fc..99c5f89d456 100644 --- a/homeassistant/components/openuv/diagnostics.py +++ b/homeassistant/components/openuv/diagnostics.py @@ -13,8 +13,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import OpenUV from .const import DOMAIN +from .coordinator import OpenUvCoordinator CONF_COORDINATES = "coordinates" CONF_TITLE = "title" @@ -33,9 +33,15 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - openuv: OpenUV = hass.data[DOMAIN][entry.entry_id] + coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] - return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "data": async_redact_data(openuv.data, TO_REDACT), - } + return async_redact_data( + { + "entry": entry.as_dict(), + "data": { + coordinator_name: coordinator.data + for coordinator_name, coordinator in coordinators.items() + }, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index ff28062da37..dd8d1587f49 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -28,6 +28,7 @@ from .const import ( TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ) +from .coordinator import OpenUvCoordinator ATTR_MAX_UV_TIME = "time" @@ -122,31 +123,23 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a OpenUV sensor based on a config entry.""" - openuv = hass.data[DOMAIN][entry.entry_id] + coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + async_add_entities( - [OpenUvSensor(openuv, description) for description in SENSOR_DESCRIPTIONS] + [ + OpenUvSensor(coordinators[DATA_UV], description) + for description in SENSOR_DESCRIPTIONS + ] ) class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.openuv.async_update_uv_index_data() - self.async_update_state() - @callback - def update_from_latest_data(self) -> None: + def _update_from_latest_data(self) -> None: """Update the state.""" - if (data := self.openuv.data[DATA_UV]) is None: - self._attr_available = False - return - - self._attr_available = True + data = self.coordinator.data if self.entity_description.key == TYPE_CURRENT_OZONE_LEVEL: self._attr_native_value = data["ozone"] diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 0fb88d9cda4..84e8a691255 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -1,6 +1,5 @@ """Test OpenUV diagnostics.""" from homeassistant.components.diagnostics import REDACTED -from homeassistant.const import CONF_ENTITY_ID from homeassistant.setup import async_setup_component from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -9,12 +8,6 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): """Test config entry diagnostics.""" await async_setup_component(hass, "homeassistant", {}) - await hass.services.async_call( - "homeassistant", - "update_entity", - {CONF_ENTITY_ID: ["sensor.current_uv_index"]}, - blocking=True, - ) assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { "entry_id": config_entry.entry_id,