Add coordinator to ring integration (#107088)

This commit is contained in:
Steven B 2024-01-31 09:37:55 +00:00 committed by GitHub
parent 7fe4a343f9
commit f725258ea9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 343 additions and 388 deletions

View file

@ -1,36 +1,25 @@
"""Support for Ring Doorbell/Chimes.""" """Support for Ring Doorbell/Chimes."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import timedelta
from functools import partial from functools import partial
import logging import logging
from typing import Any
import ring_doorbell import ring_doorbell
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.event import async_track_time_interval
from .const import ( from .const import (
DEVICES_SCAN_INTERVAL,
DOMAIN, DOMAIN,
HEALTH_SCAN_INTERVAL,
HISTORY_SCAN_INTERVAL,
NOTIFICATIONS_SCAN_INTERVAL,
PLATFORMS, PLATFORMS,
RING_API, RING_API,
RING_DEVICES, RING_DEVICES,
RING_DEVICES_COORDINATOR, RING_DEVICES_COORDINATOR,
RING_HEALTH_COORDINATOR,
RING_HISTORY_COORDINATOR,
RING_NOTIFICATIONS_COORDINATOR, RING_NOTIFICATIONS_COORDINATOR,
) )
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -53,42 +42,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
ring = ring_doorbell.Ring(auth) ring = ring_doorbell.Ring(auth)
try: devices_coordinator = RingDataCoordinator(hass, ring)
await hass.async_add_executor_job(ring.update_data) notifications_coordinator = RingNotificationsCoordinator(hass, ring)
except ring_doorbell.AuthenticationError as err: await devices_coordinator.async_config_entry_first_refresh()
_LOGGER.warning("Ring access token is no longer valid, need to re-authenticate") await notifications_coordinator.async_config_entry_first_refresh()
raise ConfigEntryAuthFailed(err) from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
RING_API: ring, RING_API: ring,
RING_DEVICES: ring.devices(), RING_DEVICES: ring.devices(),
RING_DEVICES_COORDINATOR: GlobalDataUpdater( RING_DEVICES_COORDINATOR: devices_coordinator,
hass, "device", entry, ring, "update_devices", DEVICES_SCAN_INTERVAL RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator,
),
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,
),
} }
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -99,10 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_refresh_all(_: ServiceCall) -> None: async def async_refresh_all(_: ServiceCall) -> None:
"""Refresh all ring data.""" """Refresh all ring data."""
for info in hass.data[DOMAIN].values(): for info in hass.data[DOMAIN].values():
await info["device_data"].async_refresh_all() await info[RING_DEVICES_COORDINATOR].async_refresh()
await info["dings_data"].async_refresh_all() await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh()
await hass.async_add_executor_job(info["history_data"].refresh_all)
await hass.async_add_executor_job(info["health_data"].refresh_all)
# register service # register service
hass.services.async_register(DOMAIN, "update", async_refresh_all) hass.services.async_register(DOMAIN, "update", async_refresh_all)
@ -131,173 +92,3 @@ async def async_remove_config_entry_device(
) -> bool: ) -> bool:
"""Remove a config entry from a device.""" """Remove a config entry from a device."""
return True 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)

View file

@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR 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) @dataclass(frozen=True)
@ -55,9 +56,12 @@ async def async_setup_entry(
"""Set up the Ring binary sensors from a config entry.""" """Set up the Ring binary sensors from a config entry."""
ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] ring = hass.data[DOMAIN][config_entry.entry_id][RING_API]
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][
config_entry.entry_id
][RING_NOTIFICATIONS_COORDINATOR]
entities = [ 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 device_type in ("doorbots", "authorized_doorbots", "stickup_cams")
for description in BINARY_SENSOR_TYPES for description in BINARY_SENSOR_TYPES
if device_type in description.category if device_type in description.category
@ -67,7 +71,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class RingBinarySensor(RingEntityMixin, BinarySensorEntity): class RingBinarySensor(RingEntity, BinarySensorEntity):
"""A binary sensor implementation for Ring device.""" """A binary sensor implementation for Ring device."""
_active_alert: dict[str, Any] | None = None _active_alert: dict[str, Any] | None = None
@ -75,38 +79,26 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity):
def __init__( def __init__(
self, self,
config_entry_id,
ring, ring,
device, device,
coordinator,
description: RingBinarySensorEntityDescription, description: RingBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize a sensor for Ring device.""" """Initialize a sensor for Ring device."""
super().__init__(config_entry_id, device) super().__init__(
device,
coordinator,
)
self.entity_description = description self.entity_description = description
self._ring = ring self._ring = ring
self._attr_unique_id = f"{device.id}-{description.key}" self._attr_unique_id = f"{device.id}-{description.key}"
self._update_alert() 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 @callback
def _dings_update_callback(self): def _handle_coordinator_update(self, _=None):
"""Call update method.""" """Call update method."""
self._update_alert() self._update_alert()
self.async_write_ha_state() super()._handle_coordinator_update()
@callback @callback
def _update_alert(self): def _update_alert(self):

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from itertools import chain from itertools import chain
import logging import logging
from typing import Optional
from haffmpeg.camera import CameraMjpeg from haffmpeg.camera import CameraMjpeg
import requests import requests
@ -16,8 +17,9 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import DOMAIN, RING_DEVICES, RING_HISTORY_COORDINATOR from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from .entity import RingEntityMixin from .coordinator import RingDataCoordinator
from .entity import RingEntity
FORCE_REFRESH_INTERVAL = timedelta(minutes=3) FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
@ -31,6 +33,9 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up a Ring Door Bell and StickUp Camera.""" """Set up a Ring Door Bell and StickUp Camera."""
devices = hass.data[DOMAIN][config_entry.entry_id][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
]
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
cams = [] cams = []
@ -40,19 +45,20 @@ async def async_setup_entry(
if not camera.has_subscription: if not camera.has_subscription:
continue continue
cams.append(RingCam(config_entry.entry_id, ffmpeg_manager, camera)) cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager))
async_add_entities(cams) async_add_entities(cams)
class RingCam(RingEntityMixin, Camera): class RingCam(RingEntity, Camera):
"""An implementation of a Ring Door Bell camera.""" """An implementation of a Ring Door Bell camera."""
_attr_name = None _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.""" """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._ffmpeg_manager = ffmpeg_manager
self._last_event = None self._last_event = None
@ -62,25 +68,12 @@ class RingCam(RingEntityMixin, Camera):
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
self._attr_unique_id = device.id 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 @callback
def _history_update_callback(self, history_data): def _handle_coordinator_update(self):
"""Call update method.""" """Call update method."""
history_data: Optional[list]
if not (history_data := self._get_coordinator_history()):
return
if history_data: if history_data:
self._last_event = history_data[0] self._last_event = history_data[0]
self.async_schedule_update_ha_state(True) self.async_schedule_update_ha_state(True)

View file

@ -23,17 +23,13 @@ PLATFORMS = [
] ]
DEVICES_SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5)
HISTORY_SCAN_INTERVAL = timedelta(minutes=1)
HEALTH_SCAN_INTERVAL = timedelta(minutes=1)
RING_API = "api" RING_API = "api"
RING_DEVICES = "devices" RING_DEVICES = "devices"
RING_DEVICES_COORDINATOR = "device_data" RING_DEVICES_COORDINATOR = "device_data"
RING_NOTIFICATIONS_COORDINATOR = "dings_data" RING_NOTIFICATIONS_COORDINATOR = "dings_data"
RING_HISTORY_COORDINATOR = "history_data"
RING_HEALTH_COORDINATOR = "health_data"
CONF_2FA = "2fa" CONF_2FA = "2fa"

View file

@ -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)

View file

@ -1,49 +1,71 @@
"""Base class for Ring entity.""" """Base class for Ring entity."""
from typing import TypeVar
from ring_doorbell.generic import RingGeneric
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo 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, RING_DEVICES_COORDINATOR from .const import ATTRIBUTION, DOMAIN
from .coordinator import (
RingDataCoordinator,
RingDeviceData,
RingNotificationsCoordinator,
)
_RingCoordinatorT = TypeVar(
"_RingCoordinatorT",
bound=(RingDataCoordinator | RingNotificationsCoordinator),
)
class RingEntityMixin(Entity): class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
"""Base implementation for Ring device.""" """Base implementation for Ring device."""
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True _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.""" """Initialize a sensor for Ring device."""
super().__init__() super().__init__(coordinator, context=device.id)
self._config_entry_id = config_entry_id
self._device = device self._device = device
self._attr_extra_state_attributes = {} self._attr_extra_state_attributes = {}
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_id)}, identifiers={(DOMAIN, device.device_id)}, # device_id is the mac
manufacturer="Ring", manufacturer="Ring",
model=device.model, model=device.model,
name=device.name, name=device.name,
) )
async def async_added_to_hass(self) -> None: def _get_coordinator_device_data(self) -> RingDeviceData | None:
"""Register callbacks.""" if (data := self.coordinator.data) and (
self.ring_objects[RING_DEVICES_COORDINATOR].async_add_listener( device_data := data.get(self._device.id)
self._update_callback ):
) return device_data
return None
async def async_will_remove_from_hass(self) -> None: def _get_coordinator_device(self) -> RingGeneric | None:
"""Disconnect callbacks.""" if (device_data := self._get_coordinator_device_data()) and (
self.ring_objects[RING_DEVICES_COORDINATOR].async_remove_listener( device := device_data.device
self._update_callback ):
) 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 @callback
def _update_callback(self) -> None: def _handle_coordinator_update(self) -> None:
"""Call update method.""" if device := self._get_coordinator_device():
self.async_write_ha_state() self._device = device
super()._handle_coordinator_update()
@property
def ring_objects(self):
"""Return the Ring API objects."""
return self.hass.data[DOMAIN][self._config_entry_id]

View file

@ -4,6 +4,8 @@ import logging
from typing import Any from typing import Any
import requests import requests
from ring_doorbell import RingStickUpCam
from ring_doorbell.generic import RingGeneric
from homeassistant.components.light import ColorMode, LightEntity from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -11,8 +13,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import DOMAIN, RING_DEVICES from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from .entity import RingEntityMixin from .coordinator import RingDataCoordinator
from .entity import RingEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -35,38 +38,42 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the lights for the Ring devices.""" """Create the lights for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][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 = [] lights = []
for device in devices["stickup_cams"]: for device in devices["stickup_cams"]:
if device.has_capability("light"): if device.has_capability("light"):
lights.append(RingLight(config_entry.entry_id, device)) lights.append(RingLight(device, devices_coordinator))
async_add_entities(lights) async_add_entities(lights)
class RingLight(RingEntityMixin, LightEntity): class RingLight(RingEntity, LightEntity):
"""Creates a switch to turn the ring cameras light on and off.""" """Creates a switch to turn the ring cameras light on and off."""
_attr_color_mode = ColorMode.ONOFF _attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF} _attr_supported_color_modes = {ColorMode.ONOFF}
_attr_translation_key = "light" _attr_translation_key = "light"
def __init__(self, config_entry_id, device): def __init__(self, device: RingGeneric, coordinator) -> None:
"""Initialize the light.""" """Initialize the light."""
super().__init__(config_entry_id, device) super().__init__(device, coordinator)
self._attr_unique_id = device.id self._attr_unique_id = device.id
self._attr_is_on = device.lights == ON_STATE self._attr_is_on = device.lights == ON_STATE
self._no_updates_until = dt_util.utcnow() self._no_updates_until = dt_util.utcnow()
@callback @callback
def _update_callback(self): def _handle_coordinator_update(self):
"""Call update method.""" """Call update method."""
if self._no_updates_until > dt_util.utcnow(): if self._no_updates_until > dt_util.utcnow():
return return
if (device := self._get_coordinator_device()) and isinstance(
self._attr_is_on = self._device.lights == ON_STATE device, RingStickUpCam
self.async_write_ha_state() ):
self._attr_is_on = device.lights == ON_STATE
super()._handle_coordinator_update()
def _set_light(self, new_state): def _set_light(self, new_state):
"""Update light state, and causes Home Assistant to correctly update.""" """Update light state, and causes Home Assistant to correctly update."""
@ -78,7 +85,7 @@ class RingLight(RingEntityMixin, LightEntity):
self._attr_is_on = new_state == ON_STATE self._attr_is_on = new_state == ON_STATE
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY 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: def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on for 30 seconds.""" """Turn the light on for 30 seconds."""

View file

@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from ring_doorbell.generic import RingGeneric
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@ -18,13 +20,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
DOMAIN, from .coordinator import RingDataCoordinator
RING_DEVICES, from .entity import RingEntity
RING_HEALTH_COORDINATOR,
RING_HISTORY_COORDINATOR,
)
from .entity import RingEntityMixin
async def async_setup_entry( async def async_setup_entry(
@ -34,9 +32,12 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up a sensor for a Ring device.""" """Set up a sensor for a Ring device."""
devices = hass.data[DOMAIN][config_entry.entry_id][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
]
entities = [ 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 device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams")
for description in SENSOR_TYPES for description in SENSOR_TYPES
if device_type in description.category if device_type in description.category
@ -47,19 +48,19 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class RingSensor(RingEntityMixin, SensorEntity): class RingSensor(RingEntity, SensorEntity):
"""A sensor implementation for Ring device.""" """A sensor implementation for Ring device."""
entity_description: RingSensorEntityDescription entity_description: RingSensorEntityDescription
def __init__( def __init__(
self, self,
config_entry_id, device: RingGeneric,
device, coordinator: RingDataCoordinator,
description: RingSensorEntityDescription, description: RingSensorEntityDescription,
) -> None: ) -> None:
"""Initialize a sensor for Ring device.""" """Initialize a sensor for Ring device."""
super().__init__(config_entry_id, device) super().__init__(device, coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{device.id}-{description.key}" 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. # These sensors are data hungry and not useful. Disable by default.
_attr_entity_registry_enabled_default = False _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 @property
def native_value(self): def native_value(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
@ -117,26 +97,10 @@ class HistoryRingSensor(RingSensor):
_latest_event: dict[str, Any] | None = None _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 @callback
def _history_update_callback(self, history_data): def _handle_coordinator_update(self):
"""Call update method.""" """Call update method."""
if not history_data: if not (history_data := self._get_coordinator_history()):
return return
kind = self.entity_description.kind kind = self.entity_description.kind
@ -153,7 +117,7 @@ class HistoryRingSensor(RingSensor):
return return
self._latest_event = found self._latest_event = found
self.async_write_ha_state() super()._handle_coordinator_update()
@property @property
def native_value(self): def native_value(self):

View file

@ -3,14 +3,16 @@ import logging
from typing import Any from typing import Any
from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING 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.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, RING_DEVICES from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from .entity import RingEntityMixin from .coordinator import RingDataCoordinator
from .entity import RingEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -22,24 +24,27 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the sirens for the Ring devices.""" """Create the sirens for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][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 = [] sirens = []
for device in devices["chimes"]: for device in devices["chimes"]:
sirens.append(RingChimeSiren(config_entry, device)) sirens.append(RingChimeSiren(device, coordinator))
async_add_entities(sirens) async_add_entities(sirens)
class RingChimeSiren(RingEntityMixin, SirenEntity): class RingChimeSiren(RingEntity, SirenEntity):
"""Creates a siren to play the test chimes of a Chime device.""" """Creates a siren to play the test chimes of a Chime device."""
_attr_available_tones = CHIME_TEST_SOUND_KINDS _attr_available_tones = CHIME_TEST_SOUND_KINDS
_attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES
_attr_translation_key = "siren" _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.""" """Initialize a Ring Chime siren."""
super().__init__(config_entry.entry_id, device) super().__init__(device, coordinator)
# Entity class attributes # Entity class attributes
self._attr_unique_id = f"{self._device.id}-siren" self._attr_unique_id = f"{self._device.id}-siren"

View file

@ -4,6 +4,8 @@ import logging
from typing import Any from typing import Any
import requests import requests
from ring_doorbell import RingStickUpCam
from ring_doorbell.generic import RingGeneric
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -11,8 +13,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import DOMAIN, RING_DEVICES from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from .entity import RingEntityMixin from .coordinator import RingDataCoordinator
from .entity import RingEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -34,21 +37,26 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Create the switches for the Ring devices.""" """Create the switches for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][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 = [] switches = []
for device in devices["stickup_cams"]: for device in devices["stickup_cams"]:
if device.has_capability("siren"): if device.has_capability("siren"):
switches.append(SirenSwitch(config_entry.entry_id, device)) switches.append(SirenSwitch(device, coordinator))
async_add_entities(switches) async_add_entities(switches)
class BaseRingSwitch(RingEntityMixin, SwitchEntity): class BaseRingSwitch(RingEntity, SwitchEntity):
"""Represents a switch for controlling an aspect of a ring device.""" """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.""" """Initialize the switch."""
super().__init__(config_entry_id, device) super().__init__(device, coordinator)
self._device_type = device_type self._device_type = device_type
self._attr_unique_id = f"{self._device.id}-{self._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_translation_key = "siren"
_attr_icon = SIREN_ICON _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.""" """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._no_updates_until = dt_util.utcnow()
self._attr_is_on = device.siren > 0 self._attr_is_on = device.siren > 0
@callback @callback
def _update_callback(self): def _handle_coordinator_update(self):
"""Call update method.""" """Call update method."""
if self._no_updates_until > dt_util.utcnow(): if self._no_updates_until > dt_util.utcnow():
return return
self._attr_is_on = self._device.siren > 0 if (device := self._get_coordinator_device()) and isinstance(
self.async_write_ha_state() device, RingStickUpCam
):
self._attr_is_on = device.siren > 0
super()._handle_coordinator_update()
def _set_switch(self, new_state): def _set_switch(self, new_state):
"""Update switch state, and causes Home Assistant to correctly update.""" """Update switch state, and causes Home Assistant to correctly update."""

View file

@ -60,6 +60,49 @@ async def test_auth_failed_on_setup(
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR 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( async def test_auth_failure_on_global_update(
hass: HomeAssistant, hass: HomeAssistant,
requests_mock: requests_mock.Mocker, 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)) async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20))
await hass.async_block_till_done() await hass.async_block_till_done()
assert "Ring access token is no longer valid, need to re-authenticate" in [ assert "Authentication failed while fetching devices data: " in [
record.message for record in caplog.records if record.levelname == "WARNING" 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})) 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, mock_config_entry: MockConfigEntry,
caplog, caplog,
) -> None: ) -> None:
"""Test authentication failure on global data update.""" """Test authentication failure on device data update."""
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() 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)) async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20))
await hass.async_block_till_done() await hass.async_block_till_done()
assert "Ring access token is no longer valid, need to re-authenticate" in [ assert "Authentication failed while fetching devices data: " in [
record.message for record in caplog.records if record.levelname == "WARNING" 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})) 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, RingTimeout,
"Time out fetching Ring device data", "Error fetching devices data: Timeout communicating with API: ",
), ),
( (
RingError, RingError,
"Error fetching Ring device data: ", "Error fetching devices data: Error communicating with API: ",
), ),
], ],
ids=["timeout-error", "other-error"], ids=["timeout-error", "other-error"],
@ -145,7 +194,7 @@ async def test_error_on_global_update(
await hass.async_block_till_done() await hass.async_block_till_done()
assert log_msg in [ 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] assert mock_config_entry.entry_id in hass.data[DOMAIN]
@ -156,11 +205,11 @@ async def test_error_on_global_update(
[ [
( (
RingTimeout, RingTimeout,
"Time out fetching Ring history data for device aacdef123", "Error fetching devices data: Timeout communicating with API for device Front: ",
), ),
( (
RingError, 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"], ids=["timeout-error", "other-error"],
@ -186,6 +235,6 @@ async def test_error_on_device_update(
await hass.async_block_till_done() await hass.async_block_till_done()
assert log_msg in [ 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] assert mock_config_entry.entry_id in hass.data[DOMAIN]

View file

@ -1,9 +1,10 @@
"""The tests for the Ring switch platform.""" """The tests for the Ring switch platform."""
import requests_mock import requests_mock
from homeassistant.const import Platform from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .common import setup_platform from .common import setup_platform
@ -84,7 +85,13 @@ async def test_updates_work(
text=load_fixture("devices_updated.json", "ring"), 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() await hass.async_block_till_done()