Allow multiple instances of OpenUV via the homeassistant.update_entity service (#76878)

* Allow for multiple instances of the OpenUV integration

* Docstring

* Remove Repairs

* Fix tests

* Slightly faster OpenUV object lookup

* Entity update service

* Remove service descriptions

* hassfest

* Simplify strings

* Don't add UI instructions to Repairs item

* Add a throttle to entity update

* Update homeassistant/components/openuv/__init__.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Switch from Throttle to Debouncer(s)

* Keep dispatcher for services

* Reduce change surface area

* Duplicate method

* Add issue registry through helper

* Update deprecation version

* Use config entry selector

* Remove device/service info

* Remove commented out method

* Correct entity IDs and better verbiage

* Fix tests

* Handle missing config entry ID in service calls

* Remove unhelpful comment

* Remove unused constants

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Aaron Bach 2022-09-17 17:56:45 -06:00 committed by GitHub
parent b87c452106
commit ca5a9c9456
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 267 additions and 28 deletions

View file

@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
from typing import Any from typing import Any
from pyopenuv import Client from pyopenuv import Client
from pyopenuv.errors import OpenUvError 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 ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_BINARY_SENSORS, CONF_BINARY_SENSORS,
@ -19,12 +21,18 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError 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 ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
async_dispatcher_send, async_dispatcher_send,
) )
from homeassistant.helpers.entity import Entity, EntityDescription 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 homeassistant.helpers.service import verify_domain_control
from .const import ( from .const import (
@ -38,15 +46,81 @@ from .const import (
LOGGER, LOGGER,
) )
DEFAULT_ATTRIBUTION = "Data provided by OpenUV" CONF_ENTRY_ID = "entry_id"
NOTIFICATION_ID = "openuv_notification" DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60
NOTIFICATION_TITLE = "OpenUV Component Setup"
TOPIC_UPDATE = f"{DOMAIN}_data_update" TOPIC_UPDATE = f"{DOMAIN}_data_update"
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up OpenUV as config entry.""" """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) websession = aiohttp_client.async_get_clientsession(hass)
openuv = OpenUV( openuv = OpenUV(
hass,
entry, entry,
Client( Client(
entry.data[CONF_API_KEY], 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) 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 @_verify_domain_control
async def update_data(_: ServiceCall) -> None: @extract_openuv
async def update_data(call: ServiceCall, openuv: OpenUV) -> None:
"""Refresh all OpenUV data.""" """Refresh all OpenUV data."""
LOGGER.debug("Refreshing 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() await openuv.async_update()
async_dispatcher_send(hass, TOPIC_UPDATE) async_dispatcher_send(hass, TOPIC_UPDATE)
@_verify_domain_control @_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.""" """Refresh OpenUV UV index data."""
LOGGER.debug("Refreshing 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() await openuv.async_update_uv_index_data()
async_dispatcher_send(hass, TOPIC_UPDATE) async_dispatcher_send(hass, TOPIC_UPDATE)
@_verify_domain_control @_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.""" """Refresh OpenUV protection window data."""
LOGGER.debug("Refreshing 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() await openuv.async_update_protection_data()
async_dispatcher_send(hass, TOPIC_UPDATE) 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 ( for service, method in (
("update_data", update_data), (SERVICE_NAME_UPDATE_DATA, update_data),
("update_uv_index_data", update_uv_index_data), (SERVICE_NAME_UPDATE_UV_INDEX_DATA, update_uv_index_data),
("update_protection_data", update_protection_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 return True
@ -119,6 +251,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) 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 return unload_ok
@ -143,13 +286,29 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
class OpenUV: class OpenUV:
"""Define a generic OpenUV object.""" """Define a generic OpenUV object."""
def __init__(self, entry: ConfigEntry, client: Client) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: Client) -> None:
"""Initialize.""" """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._entry = entry
self.client = client self.client = client
self.data: dict[str, Any] = {DATA_PROTECTION_WINDOW: {}, DATA_UV: {}} 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.""" """Update binary sensor (protection window) data."""
low = self._entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) low = self._entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW)
high = self._entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_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") 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.""" """Update sensor (uv index, etc) data."""
try: try:
data = await self.client.uv_index() data = await self.client.uv_index()
@ -174,6 +333,14 @@ class OpenUV:
self.data[DATA_UV] = data.get("result") 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: async def async_update(self) -> None:
"""Update sensor/binary sensor data.""" """Update sensor/binary sensor data."""
tasks = [self.async_update_protection_data(), self.async_update_uv_index_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: def __init__(self, openuv: OpenUV, description: EntityDescription) -> None:
"""Initialize.""" """Initialize."""
coordinates = f"{openuv.client.latitude}, {openuv.client.longitude}"
self._attr_extra_state_attributes = {} self._attr_extra_state_attributes = {}
self._attr_should_poll = False self._attr_should_poll = False
self._attr_unique_id = ( self._attr_unique_id = f"{coordinates}_{description.key}"
f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}"
)
self.entity_description = description self.entity_description = description
self.openuv = openuv 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: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """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.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: def update_from_latest_data(self) -> None:
"""Update the sensor using the latest data.""" """Update the sensor using the latest data."""

View file

@ -36,6 +36,14 @@ async def async_setup_entry(
class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity):
"""Define a binary sensor for OpenUV.""" """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 @callback
def update_from_latest_data(self) -> None: def update_from_latest_data(self) -> None:
"""Update the state.""" """Update the state."""

View file

@ -131,6 +131,14 @@ async def async_setup_entry(
class OpenUvSensor(OpenUvEntity, SensorEntity): class OpenUvSensor(OpenUvEntity, SensorEntity):
"""Define a binary sensor for OpenUV.""" """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 @callback
def update_from_latest_data(self) -> None: def update_from_latest_data(self) -> None:
"""Update the state.""" """Update the state."""

View file

@ -2,11 +2,35 @@
update_data: update_data:
name: Update data name: Update data
description: Request new data from OpenUV. Consumes two API calls. 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: update_uv_index_data:
name: Update UV index data name: Update UV index data
description: Request new UV index data from OpenUV. 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: update_protection_data:
name: Update protection data name: Update protection data
description: Request new protection window data from OpenUV. 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

View file

@ -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."
}
} }
} }

View file

@ -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": { "options": {
"step": { "step": {
"init": { "init": {

View file

@ -56,6 +56,8 @@ def data_uv_index_fixture():
async def setup_openuv_fixture(hass, config, data_protection_window, data_uv_index): async def setup_openuv_fixture(hass, config, data_protection_window, data_uv_index):
"""Define a fixture to set up OpenUV.""" """Define a fixture to set up OpenUV."""
with patch( 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 "homeassistant.components.openuv.Client.uv_index", return_value=data_uv_index
), patch( ), patch(
"homeassistant.components.openuv.Client.uv_protection_window", "homeassistant.components.openuv.Client.uv_protection_window",

View file

@ -1,12 +1,15 @@
"""Test OpenUV diagnostics.""" """Test OpenUV diagnostics."""
from homeassistant.components.diagnostics import REDACTED from homeassistant.components.diagnostics import REDACTED
from homeassistant.components.openuv import CONF_ENTRY_ID
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv):
"""Test config entry diagnostics.""" """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) == { assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {
"entry": { "entry": {
"data": { "data": {