diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 8a93d5a776885b..26fdc6d0575cac 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -1,36 +1,25 @@ """Support for Ring Doorbell/Chimes.""" from __future__ import annotations -import asyncio -from collections.abc import Callable -from datetime import timedelta from functools import partial import logging -from typing import Any import ring_doorbell from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.event import async_track_time_interval from .const import ( - DEVICES_SCAN_INTERVAL, DOMAIN, - HEALTH_SCAN_INTERVAL, - HISTORY_SCAN_INTERVAL, - NOTIFICATIONS_SCAN_INTERVAL, PLATFORMS, RING_API, RING_DEVICES, RING_DEVICES_COORDINATOR, - RING_HEALTH_COORDINATOR, - RING_HISTORY_COORDINATOR, RING_NOTIFICATIONS_COORDINATOR, ) +from .coordinator import RingDataCoordinator, RingNotificationsCoordinator _LOGGER = logging.getLogger(__name__) @@ -53,42 +42,16 @@ def token_updater(token): ) ring = ring_doorbell.Ring(auth) - try: - await hass.async_add_executor_job(ring.update_data) - except ring_doorbell.AuthenticationError as err: - _LOGGER.warning("Ring access token is no longer valid, need to re-authenticate") - raise ConfigEntryAuthFailed(err) from err + devices_coordinator = RingDataCoordinator(hass, ring) + notifications_coordinator = RingNotificationsCoordinator(hass, ring) + await devices_coordinator.async_config_entry_first_refresh() + await notifications_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { RING_API: ring, RING_DEVICES: ring.devices(), - RING_DEVICES_COORDINATOR: GlobalDataUpdater( - hass, "device", entry, ring, "update_devices", DEVICES_SCAN_INTERVAL - ), - RING_NOTIFICATIONS_COORDINATOR: GlobalDataUpdater( - hass, - "active dings", - entry, - ring, - "update_dings", - NOTIFICATIONS_SCAN_INTERVAL, - ), - RING_HISTORY_COORDINATOR: DeviceDataUpdater( - hass, - "history", - entry, - ring, - lambda device: device.history(limit=10), - HISTORY_SCAN_INTERVAL, - ), - RING_HEALTH_COORDINATOR: DeviceDataUpdater( - hass, - "health", - entry, - ring, - lambda device: device.update_health_data(), - HEALTH_SCAN_INTERVAL, - ), + RING_DEVICES_COORDINATOR: devices_coordinator, + RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -99,10 +62,8 @@ def token_updater(token): async def async_refresh_all(_: ServiceCall) -> None: """Refresh all ring data.""" for info in hass.data[DOMAIN].values(): - await info["device_data"].async_refresh_all() - await info["dings_data"].async_refresh_all() - await hass.async_add_executor_job(info["history_data"].refresh_all) - await hass.async_add_executor_job(info["health_data"].refresh_all) + await info[RING_DEVICES_COORDINATOR].async_refresh() + await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) @@ -131,173 +92,3 @@ async def async_remove_config_entry_device( ) -> bool: """Remove a config entry from a device.""" return True - - -class GlobalDataUpdater: - """Data storage for single API endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - data_type: str, - config_entry: ConfigEntry, - ring: ring_doorbell.Ring, - update_method: str, - update_interval: timedelta, - ) -> None: - """Initialize global data updater.""" - self.hass = hass - self.data_type = data_type - self.config_entry = config_entry - self.ring = ring - self.update_method = update_method - self.update_interval = update_interval - self.listeners: list[Callable[[], None]] = [] - self._unsub_interval = None - - @callback - def async_add_listener(self, update_callback): - """Listen for data updates.""" - # This is the first listener, set up interval. - if not self.listeners: - self._unsub_interval = async_track_time_interval( - self.hass, self.async_refresh_all, self.update_interval - ) - - self.listeners.append(update_callback) - - @callback - def async_remove_listener(self, update_callback): - """Remove data update.""" - self.listeners.remove(update_callback) - - if not self.listeners: - self._unsub_interval() - self._unsub_interval = None - - async def async_refresh_all(self, _now: int | None = None) -> None: - """Time to update.""" - if not self.listeners: - return - - try: - await self.hass.async_add_executor_job( - getattr(self.ring, self.update_method) - ) - except ring_doorbell.AuthenticationError: - _LOGGER.warning( - "Ring access token is no longer valid, need to re-authenticate" - ) - self.config_entry.async_start_reauth(self.hass) - return - except ring_doorbell.RingTimeout: - _LOGGER.warning( - "Time out fetching Ring %s data", - self.data_type, - ) - return - except ring_doorbell.RingError as err: - _LOGGER.warning( - "Error fetching Ring %s data: %s", - self.data_type, - err, - ) - return - - for update_callback in self.listeners: - update_callback() - - -class DeviceDataUpdater: - """Data storage for device data.""" - - def __init__( - self, - hass: HomeAssistant, - data_type: str, - config_entry: ConfigEntry, - ring: ring_doorbell.Ring, - update_method: Callable[[ring_doorbell.Ring], Any], - update_interval: timedelta, - ) -> None: - """Initialize device data updater.""" - self.data_type = data_type - self.hass = hass - self.config_entry = config_entry - self.ring = ring - self.update_method = update_method - self.update_interval = update_interval - self.devices: dict = {} - self._unsub_interval = None - - async def async_track_device(self, device, update_callback): - """Track a device.""" - if not self.devices: - self._unsub_interval = async_track_time_interval( - self.hass, self.refresh_all, self.update_interval - ) - - if device.device_id not in self.devices: - self.devices[device.device_id] = { - "device": device, - "update_callbacks": [update_callback], - "data": None, - } - # Store task so that other concurrent requests can wait for us to finish and - # data be available. - self.devices[device.device_id]["task"] = asyncio.current_task() - self.devices[device.device_id][ - "data" - ] = await self.hass.async_add_executor_job(self.update_method, device) - self.devices[device.device_id].pop("task") - else: - self.devices[device.device_id]["update_callbacks"].append(update_callback) - # If someone is currently fetching data as part of the initialization, wait for them - if "task" in self.devices[device.device_id]: - await self.devices[device.device_id]["task"] - - update_callback(self.devices[device.device_id]["data"]) - - @callback - def async_untrack_device(self, device, update_callback): - """Untrack a device.""" - self.devices[device.device_id]["update_callbacks"].remove(update_callback) - - if not self.devices[device.device_id]["update_callbacks"]: - self.devices.pop(device.device_id) - - if not self.devices: - self._unsub_interval() - self._unsub_interval = None - - def refresh_all(self, _=None): - """Refresh all registered devices.""" - for device_id, info in self.devices.items(): - try: - data = info["data"] = self.update_method(info["device"]) - except ring_doorbell.AuthenticationError: - _LOGGER.warning( - "Ring access token is no longer valid, need to re-authenticate" - ) - self.hass.loop.call_soon_threadsafe( - self.config_entry.async_start_reauth, self.hass - ) - return - except ring_doorbell.RingTimeout: - _LOGGER.warning( - "Time out fetching Ring %s data for device %s", - self.data_type, - device_id, - ) - continue - except ring_doorbell.RingError as err: - _LOGGER.warning( - "Error fetching Ring %s data for device %s: %s", - self.data_type, - device_id, - err, - ) - continue - - for update_callback in info["update_callbacks"]: - self.hass.loop.call_soon_threadsafe(update_callback, data) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 27eb82d34eea00..a7e04f4cfb94d8 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR -from .entity import RingEntityMixin +from .coordinator import RingNotificationsCoordinator +from .entity import RingEntity @dataclass(frozen=True) @@ -55,9 +56,12 @@ async def async_setup_entry( """Set up the Ring binary sensors from a config entry.""" ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ][RING_NOTIFICATIONS_COORDINATOR] entities = [ - RingBinarySensor(config_entry.entry_id, ring, device, description) + RingBinarySensor(ring, device, notifications_coordinator, description) for device_type in ("doorbots", "authorized_doorbots", "stickup_cams") for description in BINARY_SENSOR_TYPES if device_type in description.category @@ -67,7 +71,7 @@ async def async_setup_entry( async_add_entities(entities) -class RingBinarySensor(RingEntityMixin, BinarySensorEntity): +class RingBinarySensor(RingEntity, BinarySensorEntity): """A binary sensor implementation for Ring device.""" _active_alert: dict[str, Any] | None = None @@ -75,38 +79,26 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): def __init__( self, - config_entry_id, ring, device, + coordinator, description: RingBinarySensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" - super().__init__(config_entry_id, device) + super().__init__( + device, + coordinator, + ) self.entity_description = description self._ring = ring self._attr_unique_id = f"{device.id}-{description.key}" self._update_alert() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_add_listener( - self._dings_update_callback - ) - self._dings_update_callback() - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_remove_listener( - self._dings_update_callback - ) - @callback - def _dings_update_callback(self): + def _handle_coordinator_update(self, _=None): """Call update method.""" self._update_alert() - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _update_alert(self): diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 196d34600d143d..265d7102b91431 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -4,6 +4,7 @@ from datetime import timedelta from itertools import chain import logging +from typing import Optional from haffmpeg.camera import CameraMjpeg import requests @@ -16,8 +17,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, RING_DEVICES, RING_HISTORY_COORDINATOR -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity FORCE_REFRESH_INTERVAL = timedelta(minutes=3) @@ -31,6 +33,9 @@ async def async_setup_entry( ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] @@ -40,19 +45,20 @@ async def async_setup_entry( if not camera.has_subscription: continue - cams.append(RingCam(config_entry.entry_id, ffmpeg_manager, camera)) + cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager)) async_add_entities(cams) -class RingCam(RingEntityMixin, Camera): +class RingCam(RingEntity, Camera): """An implementation of a Ring Door Bell camera.""" _attr_name = None - def __init__(self, config_entry_id, ffmpeg_manager, device): + def __init__(self, device, coordinator, ffmpeg_manager): """Initialize a Ring Door Bell camera.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) + Camera.__init__(self) self._ffmpeg_manager = ffmpeg_manager self._last_event = None @@ -62,25 +68,12 @@ def __init__(self, config_entry_id, ffmpeg_manager, device): self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL self._attr_unique_id = device.id - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( - self._device, self._history_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( - self._device, self._history_update_callback - ) - @callback - def _history_update_callback(self, history_data): + def _handle_coordinator_update(self): """Call update method.""" + history_data: Optional[list] + if not (history_data := self._get_coordinator_history()): + return if history_data: self._last_event = history_data[0] self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 4f208e4f63efed..f0e0c63d77887b 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -23,17 +23,13 @@ ] -DEVICES_SCAN_INTERVAL = timedelta(minutes=1) +SCAN_INTERVAL = timedelta(minutes=1) NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) -HISTORY_SCAN_INTERVAL = timedelta(minutes=1) -HEALTH_SCAN_INTERVAL = timedelta(minutes=1) RING_API = "api" RING_DEVICES = "devices" RING_DEVICES_COORDINATOR = "device_data" RING_NOTIFICATIONS_COORDINATOR = "dings_data" -RING_HISTORY_COORDINATOR = "history_data" -RING_HEALTH_COORDINATOR = "health_data" CONF_2FA = "2fa" diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py new file mode 100644 index 00000000000000..35692ae2648443 --- /dev/null +++ b/homeassistant/components/ring/coordinator.py @@ -0,0 +1,118 @@ +"""Data coordinators for the ring integration.""" +from asyncio import TaskGroup +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Optional + +import ring_doorbell +from ring_doorbell.generic import RingGeneric + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def _call_api( + hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = "" +): + try: + return await hass.async_add_executor_job(target, *args) + except ring_doorbell.AuthenticationError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ring_doorbell.RingTimeout as err: + raise UpdateFailed( + f"Timeout communicating with API{msg_suffix}: {err}" + ) from err + except ring_doorbell.RingError as err: + raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err + + +@dataclass +class RingDeviceData: + """RingDeviceData.""" + + device: RingGeneric + history: Optional[list] = None + + +class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): + """Base class for device coordinators.""" + + def __init__( + self, + hass: HomeAssistant, + ring_api: ring_doorbell.Ring, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + name="devices", + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + self.ring_api: ring_doorbell.Ring = ring_api + self.first_call: bool = True + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + update_method: str = "update_data" if self.first_call else "update_devices" + await _call_api(self.hass, getattr(self.ring_api, update_method)) + self.first_call = False + data: dict[str, RingDeviceData] = {} + devices: dict[str : list[RingGeneric]] = self.ring_api.devices() + subscribed_device_ids = set(self.async_contexts()) + for device_type in devices: + for device in devices[device_type]: + # Don't update all devices in the ring api, only those that set + # their device id as context when they subscribed. + if device.id in subscribed_device_ids: + data[device.id] = RingDeviceData(device=device) + try: + async with TaskGroup() as tg: + if hasattr(device, "history"): + history_task = tg.create_task( + _call_api( + self.hass, + lambda device: device.history(limit=10), + device, + msg_suffix=f" for device {device.name}", # device_id is the mac + ) + ) + tg.create_task( + _call_api( + self.hass, + device.update_health_data, + msg_suffix=f" for device {device.name}", + ) + ) + if history_task: + data[device.id].history = history_task.result() + except ExceptionGroup as eg: + raise eg.exceptions[0] + + return data + + +class RingNotificationsCoordinator(DataUpdateCoordinator[None]): + """Global notifications coordinator.""" + + def __init__(self, hass: HomeAssistant, ring_api: ring_doorbell.Ring) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name="active dings", + update_interval=NOTIFICATIONS_SCAN_INTERVAL, + ) + self.ring_api: ring_doorbell.Ring = ring_api + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + await _call_api(self.hass, self.ring_api.update_dings) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 4896ea2db8bfc5..78f0c8e468e064 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,49 +1,71 @@ """Base class for Ring entity.""" +from typing import TypeVar + +from ring_doorbell.generic import RingGeneric + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import ( + RingDataCoordinator, + RingDeviceData, + RingNotificationsCoordinator, +) -from .const import ATTRIBUTION, DOMAIN, RING_DEVICES_COORDINATOR +_RingCoordinatorT = TypeVar( + "_RingCoordinatorT", + bound=(RingDataCoordinator | RingNotificationsCoordinator), +) -class RingEntityMixin(Entity): +class RingEntity(CoordinatorEntity[_RingCoordinatorT]): """Base implementation for Ring device.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, config_entry_id, device): + def __init__( + self, + device: RingGeneric, + coordinator: _RingCoordinatorT, + ) -> None: """Initialize a sensor for Ring device.""" - super().__init__() - self._config_entry_id = config_entry_id + super().__init__(coordinator, context=device.id) self._device = device self._attr_extra_state_attributes = {} self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_id)}, + identifiers={(DOMAIN, device.device_id)}, # device_id is the mac manufacturer="Ring", model=device.model, name=device.name, ) - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.ring_objects[RING_DEVICES_COORDINATOR].async_add_listener( - self._update_callback - ) + def _get_coordinator_device_data(self) -> RingDeviceData | None: + if (data := self.coordinator.data) and ( + device_data := data.get(self._device.id) + ): + return device_data + return None - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - self.ring_objects[RING_DEVICES_COORDINATOR].async_remove_listener( - self._update_callback - ) + def _get_coordinator_device(self) -> RingGeneric | None: + if (device_data := self._get_coordinator_device_data()) and ( + device := device_data.device + ): + return device + return None + + def _get_coordinator_history(self) -> list | None: + if (device_data := self._get_coordinator_device_data()) and ( + history := device_data.history + ): + return history + return None @callback - def _update_callback(self) -> None: - """Call update method.""" - self.async_write_ha_state() - - @property - def ring_objects(self): - """Return the Ring API objects.""" - return self.hass.data[DOMAIN][self._config_entry_id] + def _handle_coordinator_update(self) -> None: + if device := self._get_coordinator_device(): + self._device = device + super()._handle_coordinator_update() diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 7830b2547a5b90..73ec8349384dfc 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -4,6 +4,8 @@ from typing import Any import requests +from ring_doorbell import RingStickUpCam +from ring_doorbell.generic import RingGeneric from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -11,8 +13,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -35,38 +38,42 @@ async def async_setup_entry( ) -> None: """Create the lights for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] lights = [] for device in devices["stickup_cams"]: if device.has_capability("light"): - lights.append(RingLight(config_entry.entry_id, device)) + lights.append(RingLight(device, devices_coordinator)) async_add_entities(lights) -class RingLight(RingEntityMixin, LightEntity): +class RingLight(RingEntity, LightEntity): """Creates a switch to turn the ring cameras light on and off.""" _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_translation_key = "light" - def __init__(self, config_entry_id, device): + def __init__(self, device: RingGeneric, coordinator) -> None: """Initialize the light.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self._attr_unique_id = device.id self._attr_is_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() @callback - def _update_callback(self): + def _handle_coordinator_update(self): """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - - self._attr_is_on = self._device.lights == ON_STATE - self.async_write_ha_state() + if (device := self._get_coordinator_device()) and isinstance( + device, RingStickUpCam + ): + self._attr_is_on = device.lights == ON_STATE + super()._handle_coordinator_update() def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" @@ -78,7 +85,7 @@ def _set_light(self, new_state): self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY - self.async_write_ha_state() + self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the light on for 30 seconds.""" diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 0ed24f45cbddc7..356eb1c2b9b133 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import Any +from ring_doorbell.generic import RingGeneric + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -18,13 +20,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DOMAIN, - RING_DEVICES, - RING_HEALTH_COORDINATOR, - RING_HISTORY_COORDINATOR, -) -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity async def async_setup_entry( @@ -34,9 +32,12 @@ async def async_setup_entry( ) -> None: """Set up a sensor for a Ring device.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] entities = [ - description.cls(config_entry.entry_id, device, description) + description.cls(device, devices_coordinator, description) for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams") for description in SENSOR_TYPES if device_type in description.category @@ -47,19 +48,19 @@ async def async_setup_entry( async_add_entities(entities) -class RingSensor(RingEntityMixin, SensorEntity): +class RingSensor(RingEntity, SensorEntity): """A sensor implementation for Ring device.""" entity_description: RingSensorEntityDescription def __init__( self, - config_entry_id, - device, + device: RingGeneric, + coordinator: RingDataCoordinator, description: RingSensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self.entity_description = description self._attr_unique_id = f"{device.id}-{description.key}" @@ -80,27 +81,6 @@ class HealthDataRingSensor(RingSensor): # These sensors are data hungry and not useful. Disable by default. _attr_entity_registry_enabled_default = False - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HEALTH_COORDINATOR].async_track_device( - self._device, self._health_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HEALTH_COORDINATOR].async_untrack_device( - self._device, self._health_update_callback - ) - - @callback - def _health_update_callback(self, _health_data): - """Call update method.""" - self.async_write_ha_state() - @property def native_value(self): """Return the state of the sensor.""" @@ -117,26 +97,10 @@ class HistoryRingSensor(RingSensor): _latest_event: dict[str, Any] | None = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( - self._device, self._history_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( - self._device, self._history_update_callback - ) - @callback - def _history_update_callback(self, history_data): + def _handle_coordinator_update(self): """Call update method.""" - if not history_data: + if not (history_data := self._get_coordinator_history()): return kind = self.entity_description.kind @@ -153,7 +117,7 @@ def _history_update_callback(self, history_data): return self._latest_event = found - self.async_write_ha_state() + super()._handle_coordinator_update() @property def native_value(self): diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7daf7bd69ca901..0844f650e57058 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -3,14 +3,16 @@ from typing import Any from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING +from ring_doorbell.generic import RingGeneric from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -22,24 +24,27 @@ async def async_setup_entry( ) -> None: """Create the sirens for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] sirens = [] for device in devices["chimes"]: - sirens.append(RingChimeSiren(config_entry, device)) + sirens.append(RingChimeSiren(device, coordinator)) async_add_entities(sirens) -class RingChimeSiren(RingEntityMixin, SirenEntity): +class RingChimeSiren(RingEntity, SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" _attr_available_tones = CHIME_TEST_SOUND_KINDS _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES _attr_translation_key = "siren" - def __init__(self, config_entry: ConfigEntry, device) -> None: + def __init__(self, device: RingGeneric, coordinator: RingDataCoordinator) -> None: """Initialize a Ring Chime siren.""" - super().__init__(config_entry.entry_id, device) + super().__init__(device, coordinator) # Entity class attributes self._attr_unique_id = f"{self._device.id}-siren" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 074dfee9bd655e..1f06f06e32e599 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -4,6 +4,8 @@ from typing import Any import requests +from ring_doorbell import RingStickUpCam +from ring_doorbell.generic import RingGeneric from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -11,8 +13,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -34,21 +37,26 @@ async def async_setup_entry( ) -> None: """Create the switches for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] switches = [] for device in devices["stickup_cams"]: if device.has_capability("siren"): - switches.append(SirenSwitch(config_entry.entry_id, device)) + switches.append(SirenSwitch(device, coordinator)) async_add_entities(switches) -class BaseRingSwitch(RingEntityMixin, SwitchEntity): +class BaseRingSwitch(RingEntity, SwitchEntity): """Represents a switch for controlling an aspect of a ring device.""" - def __init__(self, config_entry_id, device, device_type): + def __init__( + self, device: RingGeneric, coordinator: RingDataCoordinator, device_type: str + ) -> None: """Initialize the switch.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self._device_type = device_type self._attr_unique_id = f"{self._device.id}-{self._device_type}" @@ -59,20 +67,23 @@ class SirenSwitch(BaseRingSwitch): _attr_translation_key = "siren" _attr_icon = SIREN_ICON - def __init__(self, config_entry_id, device): + def __init__(self, device: RingGeneric, coordinator: RingDataCoordinator) -> None: """Initialize the switch for a device with a siren.""" - super().__init__(config_entry_id, device, "siren") + super().__init__(device, coordinator, "siren") self._no_updates_until = dt_util.utcnow() self._attr_is_on = device.siren > 0 @callback - def _update_callback(self): + def _handle_coordinator_update(self): """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - self._attr_is_on = self._device.siren > 0 - self.async_write_ha_state() + if (device := self._get_coordinator_device()) and isinstance( + device, RingStickUpCam + ): + self._attr_is_on = device.siren > 0 + super()._handle_coordinator_update() def _set_switch(self, new_state): """Update switch state, and causes Home Assistant to correctly update.""" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 6ad79623a1220a..8d169002e38dbd 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -60,6 +60,49 @@ async def test_auth_failed_on_setup( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Timeout communicating with API: ", + ), + ( + RingError, + "Error communicating with API: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_setup( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test auth failure on setup entry.""" + mock_config_entry.add_to_hass(hass) + with patch( + "ring_doorbell.Ring.update_data", + side_effect=error_type, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert [ + record.message + for record in caplog.records + if record.levelname == "DEBUG" + and record.name == "homeassistant.config_entries" + and log_msg in record.message + and DOMAIN in record.message + ] + + async def test_auth_failure_on_global_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker, @@ -78,8 +121,11 @@ async def test_auth_failure_on_global_update( async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() - assert "Ring access token is no longer valid, need to re-authenticate" in [ - record.message for record in caplog.records if record.levelname == "WARNING" + assert "Authentication failed while fetching devices data: " in [ + record.message + for record in caplog.records + if record.levelname == "ERROR" + and record.name == "homeassistant.components.ring.coordinator" ] assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @@ -91,7 +137,7 @@ async def test_auth_failure_on_device_update( mock_config_entry: MockConfigEntry, caplog, ) -> None: - """Test authentication failure on global data update.""" + """Test authentication failure on device data update.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -103,8 +149,11 @@ async def test_auth_failure_on_device_update( async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() - assert "Ring access token is no longer valid, need to re-authenticate" in [ - record.message for record in caplog.records if record.levelname == "WARNING" + assert "Authentication failed while fetching devices data: " in [ + record.message + for record in caplog.records + if record.levelname == "ERROR" + and record.name == "homeassistant.components.ring.coordinator" ] assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @@ -115,11 +164,11 @@ async def test_auth_failure_on_device_update( [ ( RingTimeout, - "Time out fetching Ring device data", + "Error fetching devices data: Timeout communicating with API: ", ), ( RingError, - "Error fetching Ring device data: ", + "Error fetching devices data: Error communicating with API: ", ), ], ids=["timeout-error", "other-error"], @@ -145,7 +194,7 @@ async def test_error_on_global_update( await hass.async_block_till_done() assert log_msg in [ - record.message for record in caplog.records if record.levelname == "WARNING" + record.message for record in caplog.records if record.levelname == "ERROR" ] assert mock_config_entry.entry_id in hass.data[DOMAIN] @@ -156,11 +205,11 @@ async def test_error_on_global_update( [ ( RingTimeout, - "Time out fetching Ring history data for device aacdef123", + "Error fetching devices data: Timeout communicating with API for device Front: ", ), ( RingError, - "Error fetching Ring history data for device aacdef123: ", + "Error fetching devices data: Error communicating with API for device Front: ", ), ], ids=["timeout-error", "other-error"], @@ -186,6 +235,6 @@ async def test_error_on_device_update( await hass.async_block_till_done() assert log_msg in [ - record.message for record in caplog.records if record.levelname == "WARNING" + record.message for record in caplog.records if record.levelname == "ERROR" ] assert mock_config_entry.entry_id in hass.data[DOMAIN] diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 468b4f0d0ec00e..b856a2f850c510 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,10 @@ """The tests for the Ring switch platform.""" import requests_mock -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .common import setup_platform @@ -84,7 +85,13 @@ async def test_updates_work( text=load_fixture("devices_updated.json", "ring"), ) - await hass.services.async_call("ring", "update", {}, blocking=True) + await async_setup_component(hass, "homeassistant", {}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.front_siren"]}, + blocking=True, + ) await hass.async_block_till_done()