diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 29b13ff5258..bef5bb23059 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -2,12 +2,14 @@ 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 +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, @@ -19,12 +21,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import aiohttp_client +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.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control from .const import ( @@ -38,15 +46,81 @@ from .const import ( LOGGER, ) -DEFAULT_ATTRIBUTION = "Data provided by OpenUV" +CONF_ENTRY_ID = "entry_id" -NOTIFICATION_ID = "openuv_notification" -NOTIFICATION_TITLE = "OpenUV Component Setup" +DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 TOPIC_UPDATE = f"{DOMAIN}_data_update" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +SERVICE_NAME_UPDATE_DATA = "update_data" +SERVICE_NAME_UPDATE_PROTECTION_DATA = "update_protection_data" +SERVICE_NAME_UPDATE_UV_INDEX_DATA = "update_uv_index_data" + +SERVICES = ( + SERVICE_NAME_UPDATE_DATA, + SERVICE_NAME_UPDATE_PROTECTION_DATA, + SERVICE_NAME_UPDATE_UV_INDEX_DATA, +) + + +@callback +def async_get_entity_id_from_unique_id_suffix( + hass: HomeAssistant, entry: ConfigEntry, unique_id_suffix: str +) -> str: + """Get the entity ID for a config entry based on unique ID suffix.""" + ent_reg = entity_registry.async_get(hass) + [registry_entry] = [ + registry_entry + for registry_entry in ent_reg.entities.values() + if registry_entry.config_entry_id == entry.entry_id + and registry_entry.unique_id.endswith(unique_id_suffix) + ] + return registry_entry.entity_id + + +@callback +def async_log_deprecated_service_call( + hass: HomeAssistant, + call: ServiceCall, + alternate_service: str, + alternate_targets: list[str], + breaks_in_ha_version: str, +) -> None: + """Log a warning about a deprecated service call.""" + deprecated_service = f"{call.domain}.{call.service}" + + if len(alternate_targets) > 1: + translation_key = "deprecated_service_multiple_alternate_targets" + else: + translation_key = "deprecated_service_single_alternate_target" + + async_create_issue( + hass, + DOMAIN, + f"deprecated_service_{deprecated_service}", + breaks_in_ha_version=breaks_in_ha_version, + is_fixable=False, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders={ + "alternate_service": alternate_service, + "alternate_targets": ", ".join(alternate_targets), + "deprecated_service": deprecated_service, + }, + ) + + LOGGER.warning( + ( + 'The "%s" service is deprecated and will be removed in %s; review the ' + "Repairs item in the UI for more information" + ), + deprecated_service, + breaks_in_ha_version, + ) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenUV as config entry.""" @@ -54,6 +128,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = aiohttp_client.async_get_clientsession(hass) openuv = OpenUV( + hass, entry, Client( entry.data[CONF_API_KEY], @@ -82,33 +157,90 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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" + ) + protection_window_entity_id = async_get_entity_id_from_unique_id_suffix( + hass, entry, "protection_window" + ) + @_verify_domain_control - async def update_data(_: ServiceCall) -> None: + @extract_openuv + async def update_data(call: ServiceCall, openuv: OpenUV) -> None: """Refresh all OpenUV data.""" LOGGER.debug("Refreshing all OpenUV data") + async_log_deprecated_service_call( + hass, + call, + "homeassistant.update_entity", + [protection_window_entity_id, current_uv_index_entity_id], + "2022.12.0", + ) await openuv.async_update() async_dispatcher_send(hass, TOPIC_UPDATE) @_verify_domain_control - async def update_uv_index_data(_: ServiceCall) -> None: + @extract_openuv + async def update_uv_index_data(call: ServiceCall, openuv: OpenUV) -> None: """Refresh OpenUV UV index data.""" LOGGER.debug("Refreshing OpenUV UV index data") + async_log_deprecated_service_call( + hass, + call, + "homeassistant.update_entity", + [current_uv_index_entity_id], + "2022.12.0", + ) await openuv.async_update_uv_index_data() async_dispatcher_send(hass, TOPIC_UPDATE) @_verify_domain_control - async def update_protection_data(_: ServiceCall) -> None: + @extract_openuv + async def update_protection_data(call: ServiceCall, openuv: OpenUV) -> None: """Refresh OpenUV protection window data.""" LOGGER.debug("Refreshing OpenUV protection window data") + async_log_deprecated_service_call( + hass, + call, + "homeassistant.update_entity", + [protection_window_entity_id], + "2022.12.0", + ) await openuv.async_update_protection_data() async_dispatcher_send(hass, TOPIC_UPDATE) + service_schema = vol.Schema( + { + vol.Optional(CONF_ENTRY_ID, default=entry.entry_id): cv.string, + } + ) + for service, method in ( - ("update_data", update_data), - ("update_uv_index_data", update_uv_index_data), - ("update_protection_data", update_protection_data), + (SERVICE_NAME_UPDATE_DATA, update_data), + (SERVICE_NAME_UPDATE_UV_INDEX_DATA, update_uv_index_data), + (SERVICE_NAME_UPDATE_PROTECTION_DATA, update_protection_data), ): - hass.services.async_register(DOMAIN, service, method) + if hass.services.has_service(DOMAIN, service): + continue + hass.services.async_register(DOMAIN, service, method, schema=service_schema) return True @@ -119,6 +251,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # If this is the last loaded instance of OpenUV, deregister any services + # defined during integration setup: + for service_name in SERVICES: + hass.services.async_remove(DOMAIN, service_name) + return unload_ok @@ -143,13 +286,29 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class OpenUV: """Define a generic OpenUV object.""" - def __init__(self, entry: ConfigEntry, client: Client) -> None: + 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: + 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) @@ -163,7 +322,7 @@ class OpenUV: self.data[DATA_PROTECTION_WINDOW] = data.get("result") - async def async_update_uv_index_data(self) -> None: + async def _async_update_uv_index_data(self) -> None: """Update sensor (uv index, etc) data.""" try: data = await self.client.uv_index() @@ -174,6 +333,14 @@ class OpenUV: 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()] @@ -187,26 +354,33 @@ class OpenUvEntity(Entity): def __init__(self, openuv: OpenUV, description: EntityDescription) -> None: """Initialize.""" + coordinates = f"{openuv.client.latitude}, {openuv.client.longitude}" self._attr_extra_state_attributes = {} self._attr_should_poll = False - self._attr_unique_id = ( - f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}" - ) + self._attr_unique_id = f"{coordinates}_{description.key}" self.entity_description = description self.openuv = openuv + @callback + def async_update_state(self) -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + async def async_added_to_hass(self) -> None: """Register callbacks.""" - - @callback - def update() -> None: - """Update the state.""" - self.update_from_latest_data() - self.async_write_ha_state() - - self.async_on_remove(async_dispatcher_connect(self.hass, TOPIC_UPDATE, update)) - self.update_from_latest_data() + self.async_on_remove( + async_dispatcher_connect(self.hass, TOPIC_UPDATE, self.async_update_state) + ) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. Should be implemented by each + OpenUV platform. + """ + raise NotImplementedError def update_from_latest_data(self) -> None: """Update the sensor using the latest data.""" diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 757f0479e01..b1c962932b7 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -36,6 +36,14 @@ async def async_setup_entry( 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.""" diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 3a5bd3c2a47..ff28062da37 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -131,6 +131,14 @@ async def async_setup_entry( 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: """Update the state.""" diff --git a/homeassistant/components/openuv/services.yaml b/homeassistant/components/openuv/services.yaml index e4886dfa7d8..3e2e6ab0087 100644 --- a/homeassistant/components/openuv/services.yaml +++ b/homeassistant/components/openuv/services.yaml @@ -2,11 +2,35 @@ update_data: name: Update data description: Request new data from OpenUV. Consumes two API calls. + fields: + entry_id: + name: Config Entry + description: The configured instance of the OpenUV integration to use + required: true + selector: + config_entry: + integration: openuv update_uv_index_data: name: Update UV index data description: Request new UV index data from OpenUV. + fields: + entry_id: + name: Config Entry + description: The configured instance of the OpenUV integration to use + required: true + selector: + config_entry: + integration: openuv update_protection_data: name: Update protection data description: Request new protection window data from OpenUV. + fields: + entry_id: + name: Config Entry + description: The configured instance of the OpenUV integration to use + required: true + selector: + config_entry: + integration: openuv diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index cd9ec36d93a..84a093280f3 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -28,5 +28,15 @@ } } } + }, + "issues": { + "deprecated_service_multiple_alternate_targets": { + "title": "The {deprecated_service} service is being removed", + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with one of these entity IDs as the target: `{alternate_targets}`." + }, + "deprecated_service_single_alternate_target": { + "title": "The {deprecated_service} service is being removed", + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with `{alternate_targets}` as the target." + } } } diff --git a/homeassistant/components/openuv/translations/en.json b/homeassistant/components/openuv/translations/en.json index 92ca71cd46f..3879a4d7d44 100644 --- a/homeassistant/components/openuv/translations/en.json +++ b/homeassistant/components/openuv/translations/en.json @@ -18,6 +18,16 @@ } } }, + "issues": { + "deprecated_service_multiple_alternate_targets": { + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with one of these entity IDs as the target: `{alternate_targets}`.", + "title": "The {deprecated_service} service is being removed" + }, + "deprecated_service_single_alternate_target": { + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with `{alternate_targets}` as the target.", + "title": "The {deprecated_service} service is being removed" + } + }, "options": { "step": { "init": { diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index 859769f2b58..c39a84b8b4c 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -56,6 +56,8 @@ def data_uv_index_fixture(): async def setup_openuv_fixture(hass, config, data_protection_window, data_uv_index): """Define a fixture to set up OpenUV.""" with patch( + "homeassistant.components.openuv.async_get_entity_id_from_unique_id_suffix", + ), patch( "homeassistant.components.openuv.Client.uv_index", return_value=data_uv_index ), patch( "homeassistant.components.openuv.Client.uv_protection_window", diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 4999e5d2132..1196045300b 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -1,12 +1,15 @@ """Test OpenUV diagnostics.""" from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.openuv import CONF_ENTRY_ID 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 hass.services.async_call("openuv", "update_data") + await hass.services.async_call( + "openuv", "update_data", service_data={CONF_ENTRY_ID: "test_entry_id"} + ) assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { "data": {