"""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 pathlib import Path from typing import Any from oauthlib.oauth2 import AccessDeniedError import requests from ring_doorbell import Auth, Ring from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Data provided by Ring.com" NOTIFICATION_ID = "ring_notification" NOTIFICATION_TITLE = "Ring Setup" DOMAIN = "ring" DEFAULT_ENTITY_NAMESPACE = "ring" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.CAMERA, Platform.SIREN, ] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Ring component.""" if DOMAIN not in config: return True def legacy_cleanup(): """Clean up old tokens.""" old_cache = Path(hass.config.path(".ring_cache.pickle")) if old_cache.is_file(): old_cache.unlink() await hass.async_add_executor_job(legacy_cleanup) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" def token_updater(token): """Handle from sync context when token is updated.""" run_callback_threadsafe( hass.loop, partial( hass.config_entries.async_update_entry, entry, data={**entry.data, "token": token}, ), ).result() auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater) ring = Ring(auth) try: await hass.async_add_executor_job(ring.update_data) except AccessDeniedError: _LOGGER.error("Access token is no longer valid. Please set up Ring again") return False hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "api": ring, "devices": ring.devices(), "device_data": GlobalDataUpdater( hass, "device", entry.entry_id, ring, "update_devices", timedelta(minutes=1) ), "dings_data": GlobalDataUpdater( hass, "active dings", entry.entry_id, ring, "update_dings", timedelta(seconds=5), ), "history_data": DeviceDataUpdater( hass, "history", entry.entry_id, ring, lambda device: device.history(limit=10), timedelta(minutes=1), ), "health_data": DeviceDataUpdater( hass, "health", entry.entry_id, ring, lambda device: device.update_health_data(), timedelta(minutes=1), ), } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) if hass.services.has_service(DOMAIN, "update"): return True 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) # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Ring entry.""" if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False hass.data[DOMAIN].pop(entry.entry_id) if len(hass.data[DOMAIN]) != 0: return True # Last entry unloaded, clean up service hass.services.async_remove(DOMAIN, "update") return True async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> 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_id: str, ring: Ring, update_method: str, update_interval: timedelta, ) -> None: """Initialize global data updater.""" self.hass = hass self.data_type = data_type self.config_entry_id = config_entry_id 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 AccessDeniedError: _LOGGER.error("Ring access token is no longer valid. Set up Ring again") await self.hass.config_entries.async_unload(self.config_entry_id) return except requests.Timeout: _LOGGER.warning( "Time out fetching Ring %s data", self.data_type, ) return except requests.RequestException 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_id: str, ring: Ring, update_method: Callable[[Ring], Any], update_interval: timedelta, ) -> None: """Initialize device data updater.""" self.data_type = data_type self.hass = hass self.config_entry_id = config_entry_id 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 AccessDeniedError: _LOGGER.error("Ring access token is no longer valid. Set up Ring again") self.hass.add_job( self.hass.config_entries.async_unload(self.config_entry_id) ) return except requests.Timeout: _LOGGER.warning( "Time out fetching Ring %s data for device %s", self.data_type, device_id, ) continue except requests.RequestException 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)