Add coordinator to ring integration (#107088)

pull/108781/head
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."""
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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)

View File

@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant, callback
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):

View File

@ -4,6 +4,7 @@ from __future__ import annotations
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.aiohttp_client import async_aiohttp_proxy_stream
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 @@ class RingCam(RingEntityMixin, Camera):
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)

View File

@ -23,17 +23,13 @@ PLATFORMS = [
]
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"

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."""
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, 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."""
_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()

View File

@ -4,6 +4,8 @@ import logging
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.core import HomeAssistant, callback
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 @@ class RingLight(RingEntityMixin, LightEntity):
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."""

View File

@ -4,6 +4,8 @@ from __future__ import annotations
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.const import (
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 @@ class HistoryRingSensor(RingSensor):
return
self._latest_event = found
self.async_write_ha_state()
super()._handle_coordinator_update()
@property
def native_value(self):

View File

@ -3,14 +3,16 @@ import logging
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"

View File

@ -4,6 +4,8 @@ import logging
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.core import HomeAssistant, callback
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."""

View File

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

View File

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