Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add coordinator to ring integration #107088

Merged
merged 9 commits into from
Jan 31, 2024
229 changes: 10 additions & 219 deletions homeassistant/components/ring/__init__.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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)
Expand All @@ -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)
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -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)
36 changes: 14 additions & 22 deletions homeassistant/components/ring/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -67,46 +71,34 @@ 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
entity_description: RingBinarySensorEntityDescription

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):
Expand Down
Loading
Loading