From 1e82813c3b20141d9c1df81a2bafd18d2899de52 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 15 Jan 2020 08:10:42 -0800 Subject: [PATCH] Refactor Ring data handling (#30777) * Refactor Ring data handling * Add async_ to methods --- homeassistant/components/ring/__init__.py | 241 +++++++----- .../components/ring/binary_sensor.py | 103 +++-- homeassistant/components/ring/camera.py | 94 ++--- homeassistant/components/ring/config_flow.py | 3 - homeassistant/components/ring/entity.py | 53 +++ homeassistant/components/ring/light.py | 64 +--- homeassistant/components/ring/sensor.py | 354 +++++++++--------- homeassistant/components/ring/switch.py | 73 +--- tests/components/ring/test_binary_sensor.py | 11 +- 9 files changed, 514 insertions(+), 482 deletions(-) create mode 100644 homeassistant/components/ring/entity.py diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index b35ff630310..7b4fbb15b30 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -4,16 +4,16 @@ from datetime import timedelta from functools import partial import logging from pathlib import Path -from time import time +from typing import Optional +from oauthlib.oauth2 import AccessDeniedError from ring_doorbell import Auth, Ring import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.async_ import run_callback_threadsafe @@ -24,16 +24,8 @@ ATTRIBUTION = "Data provided by Ring.com" NOTIFICATION_ID = "ring_notification" NOTIFICATION_TITLE = "Ring Setup" -DATA_HISTORY = "ring_history" -DATA_HEALTH_DATA_TRACKER = "ring_health_data" -DATA_TRACK_INTERVAL = "ring_track_interval" - DOMAIN = "ring" DEFAULT_ENTITY_NAMESPACE = "ring" -SIGNAL_UPDATE_RING = "ring_update" -SIGNAL_UPDATE_HEALTH_RING = "ring_health_update" - -SCAN_INTERVAL = timedelta(seconds=10) PLATFORMS = ("binary_sensor", "light", "sensor", "switch", "camera") @@ -93,9 +85,36 @@ async def async_setup_entry(hass, entry): auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater) ring = Ring(auth) - await hass.async_add_executor_job(ring.update_data) + 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] = ring + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "api": ring, + "devices": ring.devices(), + "device_data": GlobalDataUpdater( + hass, entry.entry_id, ring, "update_devices", timedelta(minutes=1) + ), + "dings_data": GlobalDataUpdater( + hass, entry.entry_id, ring, "update_dings", timedelta(seconds=10) + ), + "history_data": DeviceDataUpdater( + hass, + entry.entry_id, + ring, + lambda device: device.history(limit=10), + timedelta(minutes=1), + ), + "health_data": DeviceDataUpdater( + hass, + entry.entry_id, + ring, + lambda device: device.update_health_data(), + timedelta(minutes=1), + ), + } for component in PLATFORMS: hass.async_create_task( @@ -105,25 +124,16 @@ async def async_setup_entry(hass, entry): if hass.services.has_service(DOMAIN, "update"): return True - async def refresh_all(_): - """Refresh all ring accounts.""" - await asyncio.gather( - *[ - hass.async_add_executor_job(api.update_data) - for api in hass.data[DOMAIN].values() - ] - ) - async_dispatcher_send(hass, SIGNAL_UPDATE_RING) + async def async_refresh_all(_): + """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", refresh_all) - - # register scan interval for ring - hass.data[DATA_TRACK_INTERVAL] = async_track_time_interval( - hass, refresh_all, SCAN_INTERVAL - ) - hass.data[DATA_HEALTH_DATA_TRACKER] = HealthDataUpdater(hass) - hass.data[DATA_HISTORY] = HistoryCache(hass) + hass.services.async_register(DOMAIN, "update", async_refresh_all) return True @@ -146,98 +156,141 @@ async def async_unload_entry(hass, entry): if len(hass.data[DOMAIN]) != 0: return True - # Last entry unloaded, clean up - hass.data.pop(DATA_TRACK_INTERVAL)() - hass.data.pop(DATA_HEALTH_DATA_TRACKER) - hass.data.pop(DATA_HISTORY) + # Last entry unloaded, clean up service hass.services.async_remove(DOMAIN, "update") return True -class HealthDataUpdater: - """Data storage for health data.""" +class GlobalDataUpdater: + """Data storage for single API endpoint.""" - def __init__(self, hass): - """Track devices that need health data updated.""" + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + ring: Ring, + update_method: str, + update_interval: timedelta, + ): + """Initialize global data updater.""" self.hass = hass + self.config_entry_id = config_entry_id + self.ring = ring + self.update_method = update_method + self.update_interval = update_interval + self.listeners = [] + 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: Optional[int] = 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 + + for update_callback in self.listeners: + update_callback() + + +class DeviceDataUpdater: + """Data storage for device data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + ring: Ring, + update_method: str, + update_interval: timedelta, + ): + """Initialize device data updater.""" + 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 = {} self._unsub_interval = None - async def track_device(self, config_entry_id, device): + 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, SCAN_INTERVAL + self.hass, self.refresh_all, self.update_interval ) - key = (config_entry_id, device.device_id) - - if key not in self.devices: - self.devices[key] = { + if device.device_id not in self.devices: + self.devices[device.device_id] = { "device": device, - "count": 1, + "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[key]["count"] += 1 + 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"] - await self.hass.async_add_executor_job(device.update_health_data) + update_callback(self.devices[device.device_id]["data"]) @callback - def untrack_device(self, config_entry_id, device): + def async_untrack_device(self, device, update_callback): """Untrack a device.""" - key = (config_entry_id, device.device_id) - self.devices[key]["count"] -= 1 + self.devices[device.device_id]["update_callbacks"].remove(update_callback) - if self.devices[key]["count"] == 0: - self.devices.pop(key) + 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, _): + def refresh_all(self, _=None): """Refresh all registered devices.""" for info in self.devices.values(): - info["device"].update_health_data() + 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 - dispatcher_send(self.hass, SIGNAL_UPDATE_HEALTH_RING) - - -class HistoryCache: - """Helper to fetch history.""" - - STALE_AFTER = 10 # seconds - - def __init__(self, hass): - """Initialize history cache.""" - self.hass = hass - self.cache = {} - - async def async_get_history(self, config_entry_id, device): - """Get history of a device.""" - key = (config_entry_id, device.device_id) - - if key in self.cache: - info = self.cache[key] - - # We're already fetching data, join that task - if "task" in info: - return await info["task"] - - # We have valid cache info, return that - if time() - info["created_at"] < self.STALE_AFTER: - return info["data"] - - self.cache.pop(key) - - # Fetch data - task = self.hass.async_add_executor_job(partial(device.history, limit=10)) - - self.cache[key] = {"task": task} - - data = await task - - self.cache[key] = {"created_at": time(), "data": data} - - return data + for update_callback in info["update_callbacks"]: + self.hass.loop.call_soon_threadsafe(update_callback, data) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2dd3682951f..7b20ff948d1 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,13 +1,12 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ATTRIBUTION, DOMAIN, SIGNAL_UPDATE_RING +from . import DOMAIN +from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -22,8 +21,8 @@ SENSOR_TYPES = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Ring binary sensors from a config entry.""" - ring = hass.data[DOMAIN][config_entry.entry_id] - devices = ring.devices() + ring = hass.data[DOMAIN][config_entry.entry_id]["api"] + devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] sensors = [] @@ -33,49 +32,62 @@ async def async_setup_entry(hass, config_entry, async_add_entities): continue for device in devices[device_type]: - sensors.append(RingBinarySensor(ring, device, sensor_type)) + sensors.append( + RingBinarySensor(config_entry.entry_id, ring, device, sensor_type) + ) - async_add_entities(sensors, True) + async_add_entities(sensors) -class RingBinarySensor(BinarySensorDevice): +class RingBinarySensor(RingEntityMixin, BinarySensorDevice): """A binary sensor implementation for Ring device.""" - def __init__(self, ring, device, sensor_type): + _active_alert = None + + def __init__(self, config_entry_id, ring, device, sensor_type): """Initialize a sensor for Ring device.""" - self._sensor_type = sensor_type + super().__init__(config_entry_id, device) self._ring = ring - self._device = device + self._sensor_type = sensor_type self._name = "{0} {1}".format( - self._device.name, SENSOR_TYPES.get(self._sensor_type)[0] + self._device.name, SENSOR_TYPES.get(sensor_type)[0] ) - self._device_class = SENSOR_TYPES.get(self._sensor_type)[2] + self._device_class = SENSOR_TYPES.get(sensor_type)[2] self._state = None - self._unique_id = f"{self._device.id}-{self._sensor_type}" - self._disp_disconnect = None + self._unique_id = f"{device.id}-{sensor_type}" + self._update_alert() async def async_added_to_hass(self): """Register callbacks.""" - self._disp_disconnect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RING, self._update_callback - ) + await super().async_added_to_hass() + self.ring_objects["dings_data"].async_add_listener(self._dings_update_callback) + self._dings_update_callback() async def async_will_remove_from_hass(self): """Disconnect callbacks.""" - if self._disp_disconnect: - self._disp_disconnect() - self._disp_disconnect = None + await super().async_will_remove_from_hass() + self.ring_objects["dings_data"].async_remove_listener( + self._dings_update_callback + ) @callback - def _update_callback(self): + def _dings_update_callback(self): """Call update method.""" - self.async_schedule_update_ha_state(True) - _LOGGER.debug("Updating Ring binary sensor %s (callback)", self.name) + self._update_alert() + self.async_write_ha_state() - @property - def should_poll(self): - """Return False, updates are controlled via the hub.""" - return False + @callback + def _update_alert(self): + """Update active alert.""" + self._active_alert = next( + ( + alert + for alert in self._ring.active_alerts() + if alert["kind"] == self._sensor_type + and alert["doorbot_id"] == self._device.id + ), + None, + ) @property def name(self): @@ -85,7 +97,7 @@ class RingBinarySensor(BinarySensorDevice): @property def is_on(self): """Return True if the binary sensor is on.""" - return self._state + return self._active_alert is not None @property def device_class(self): @@ -97,32 +109,17 @@ class RingBinarySensor(BinarySensorDevice): """Return a unique ID.""" return self._unique_id - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "model": self._device.model, - "manufacturer": "Ring", - } - @property def device_state_attributes(self): """Return the state attributes.""" - attrs = {} - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + attrs = super().device_state_attributes - if self._device.alert and self._device.alert_expires_at: - attrs["expires_at"] = self._device.alert_expires_at - attrs["state"] = self._device.alert.get("state") + if self._active_alert is None: + return attrs + + attrs["state"] = self._active_alert["state"] + attrs["expires_at"] = datetime.fromtimestamp( + self._active_alert.get("now") + self._active_alert.get("expires_in") + ).isoformat() return attrs - - async def async_update(self): - """Get the latest data and updates the state.""" - self._state = any( - alert["kind"] == self._sensor_type - and alert["doorbot_id"] == self._device.id - for alert in self._ring.active_alerts() - ) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 8ef876e4a00..07d87c85714 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -12,10 +12,10 @@ from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util -from . import ATTRIBUTION, DATA_HISTORY, DOMAIN, SIGNAL_UPDATE_RING +from . import ATTRIBUTION, DOMAIN +from .entity import RingEntityMixin FORCE_REFRESH_INTERVAL = timedelta(minutes=45) @@ -24,8 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a Ring Door Bell and StickUp Camera.""" - ring = hass.data[DOMAIN][config_entry.entry_id] - devices = ring.devices() + devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] cams = [] for camera in chain( @@ -36,42 +35,52 @@ async def async_setup_entry(hass, config_entry, async_add_entities): cams.append(RingCam(config_entry.entry_id, hass.data[DATA_FFMPEG], camera)) - async_add_entities(cams, True) + async_add_entities(cams) -class RingCam(Camera): +class RingCam(RingEntityMixin, Camera): """An implementation of a Ring Door Bell camera.""" def __init__(self, config_entry_id, ffmpeg, device): """Initialize a Ring Door Bell camera.""" - super().__init__() - self._config_entry_id = config_entry_id - self._device = device + super().__init__(config_entry_id, device) + self._name = self._device.name self._ffmpeg = ffmpeg + self._last_event = None self._last_video_id = None self._video_url = None self._utcnow = dt_util.utcnow() self._expires_at = self._utcnow - FORCE_REFRESH_INTERVAL - self._disp_disconnect = None async def async_added_to_hass(self): """Register callbacks.""" - self._disp_disconnect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RING, self._update_callback + await super().async_added_to_hass() + + await self.ring_objects["history_data"].async_track_device( + self._device, self._history_update_callback ) async def async_will_remove_from_hass(self): """Disconnect callbacks.""" - if self._disp_disconnect: - self._disp_disconnect() - self._disp_disconnect = None + await super().async_will_remove_from_hass() + + self.ring_objects["history_data"].async_untrack_device( + self._device, self._history_update_callback + ) @callback - def _update_callback(self): + def _history_update_callback(self, history_data): """Call update method.""" - self.async_schedule_update_ha_state(True) - _LOGGER.debug("Updating Ring camera %s (callback)", self.name) + if history_data: + self._last_event = history_data[0] + self.async_schedule_update_ha_state(True) + else: + self._last_event = None + self._last_video_id = None + self._video_url = None + self._expires_at = self._utcnow + self.async_write_ha_state() @property def name(self): @@ -83,16 +92,6 @@ class RingCam(Camera): """Return a unique ID.""" return self._device.id - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "model": self._device.model, - "manufacturer": "Ring", - } - @property def device_state_attributes(self): """Return the state attributes.""" @@ -104,7 +103,6 @@ class RingCam(Camera): async def async_camera_image(self): """Return a still image response from the camera.""" - ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) if self._video_url is None: @@ -136,33 +134,23 @@ class RingCam(Camera): async def async_update(self): """Update camera entity and refresh attributes.""" - _LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url") - - self._utcnow = dt_util.utcnow() - - data = await self.hass.data[DATA_HISTORY].async_get_history( - self._config_entry_id, self._device - ) - - if not data: + if self._last_event is None: return - last_event = data[0] - last_recording_id = last_event["id"] - video_status = last_event["recording"]["status"] + if self._last_event["recording"]["status"] != "ready": + return - if video_status == "ready" and ( - self._last_video_id != last_recording_id or self._utcnow >= self._expires_at + if ( + self._last_video_id == self._last_event["id"] + and self._utcnow <= self._expires_at ): + return - video_url = await self.hass.async_add_executor_job( - self._device.recording_url, last_recording_id - ) + video_url = await self.hass.async_add_executor_job( + self._device.recording_url, self._last_event["id"] + ) - if video_url: - _LOGGER.debug("Ring DoorBell properties refreshed") - - # update attributes if new video or if URL has expired - self._last_video_id = last_recording_id - self._video_url = video_url - self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow + if video_url: + self._last_video_id = self._last_event["id"] + self._video_url = video_url + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 57f873bd1a6..a25e0283753 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -39,9 +39,6 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - errors = {} if user_input is not None: try: diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py new file mode 100644 index 00000000000..6eb87cb8f9b --- /dev/null +++ b/homeassistant/components/ring/entity.py @@ -0,0 +1,53 @@ +"""Base class for Ring entity.""" +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import callback + +from . import ATTRIBUTION, DOMAIN + + +class RingEntityMixin: + """Base implementation for Ring device.""" + + def __init__(self, config_entry_id, device): + """Initialize a sensor for Ring device.""" + super().__init__() + self._config_entry_id = config_entry_id + self._device = device + + async def async_added_to_hass(self): + """Register callbacks.""" + self.ring_objects["device_data"].async_add_listener(self._update_callback) + + async def async_will_remove_from_hass(self): + """Disconnect callbacks.""" + self.ring_objects["device_data"].async_remove_listener(self._update_callback) + + @callback + def _update_callback(self): + """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] + + @property + def should_poll(self): + """Return False, updates are controlled via the hub.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.name, + "model": self._device.model, + "manufacturer": "Ring", + } diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 10572e2e0ae..86ef55af16d 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -4,10 +4,10 @@ import logging from homeassistant.components.light import Light from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.dt as dt_util -from . import DOMAIN, SIGNAL_UPDATE_RING +from . import DOMAIN +from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -25,51 +25,35 @@ OFF_STATE = "off" async def async_setup_entry(hass, config_entry, async_add_entities): """Create the lights for the Ring devices.""" - ring = hass.data[DOMAIN][config_entry.entry_id] + devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] - devices = ring.devices() lights = [] for device in devices["stickup_cams"]: if device.has_capability("light"): - lights.append(RingLight(device)) + lights.append(RingLight(config_entry.entry_id, device)) - async_add_entities(lights, True) + async_add_entities(lights) -class RingLight(Light): +class RingLight(RingEntityMixin, Light): """Creates a switch to turn the ring cameras light on and off.""" - def __init__(self, device): + def __init__(self, config_entry_id, device): """Initialize the light.""" - self._device = device - self._unique_id = self._device.id - self._light_on = False + super().__init__(config_entry_id, device) + self._unique_id = device.id + self._light_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() - self._disp_disconnect = None - - async def async_added_to_hass(self): - """Register callbacks.""" - self._disp_disconnect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RING, self._update_callback - ) - - async def async_will_remove_from_hass(self): - """Disconnect callbacks.""" - if self._disp_disconnect: - self._disp_disconnect() - self._disp_disconnect = None @callback def _update_callback(self): """Call update method.""" - _LOGGER.debug("Updating Ring light %s (callback)", self.name) - self.async_schedule_update_ha_state(True) + if self._no_updates_until > dt_util.utcnow(): + return - @property - def should_poll(self): - """Update controlled via the hub.""" - return False + self._light_on = self._device.lights == ON_STATE + self.async_write_ha_state() @property def name(self): @@ -86,22 +70,12 @@ class RingLight(Light): """If the switch is currently on or off.""" return self._light_on - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "model": self._device.model, - "manufacturer": "Ring", - } - def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" self._device.lights = new_state self._light_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY - self.async_schedule_update_ha_state(True) + self.async_schedule_update_ha_state() def turn_on(self, **kwargs): """Turn the light on for 30 seconds.""" @@ -110,11 +84,3 @@ class RingLight(Light): def turn_off(self, **kwargs): """Turn the light off.""" self._set_light(OFF_STATE) - - async def async_update(self): - """Update current state of the light.""" - if self._no_updates_until > dt_util.utcnow(): - _LOGGER.debug("Skipping update...") - return - - self._light_on = self._device.lights == ON_STATE diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index fe909636e83..2b921dddd2f 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,88 +1,20 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" import logging -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level -from . import ( - ATTRIBUTION, - DATA_HEALTH_DATA_TRACKER, - DATA_HISTORY, - DOMAIN, - SIGNAL_UPDATE_HEALTH_RING, - SIGNAL_UPDATE_RING, -) +from . import DOMAIN +from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) -# Sensor types: Name, category, units, icon, kind, device_class -SENSOR_TYPES = { - "battery": [ - "Battery", - ["doorbots", "authorized_doorbots", "stickup_cams"], - "%", - None, - None, - "battery", - ], - "last_activity": [ - "Last Activity", - ["doorbots", "authorized_doorbots", "stickup_cams"], - None, - "history", - None, - "timestamp", - ], - "last_ding": [ - "Last Ding", - ["doorbots", "authorized_doorbots"], - None, - "history", - "ding", - "timestamp", - ], - "last_motion": [ - "Last Motion", - ["doorbots", "authorized_doorbots", "stickup_cams"], - None, - "history", - "motion", - "timestamp", - ], - "volume": [ - "Volume", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - None, - "bell-ring", - None, - None, - ], - "wifi_signal_category": [ - "WiFi Signal Category", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - None, - "wifi", - None, - None, - ], - "wifi_signal_strength": [ - "WiFi Signal Strength", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - "dBm", - "wifi", - None, - "signal_strength", - ], -} - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a sensor for a Ring device.""" - ring = hass.data[DOMAIN][config_entry.entry_id] - devices = ring.devices() + devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + # Makes a ton of requests. We will make this a config entry option in the future wifi_enabled = False @@ -100,72 +32,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device_type == "battery" and device.battery_life is None: continue - sensors.append(RingSensor(config_entry.entry_id, device, sensor_type)) + sensors.append( + SENSOR_TYPES[sensor_type][6]( + config_entry.entry_id, device, sensor_type + ) + ) - async_add_entities(sensors, True) + async_add_entities(sensors) -class RingSensor(Entity): +class RingSensor(RingEntityMixin, Entity): """A sensor implementation for Ring device.""" def __init__(self, config_entry_id, device, sensor_type): """Initialize a sensor for Ring device.""" - self._config_entry_id = config_entry_id + super().__init__(config_entry_id, device) self._sensor_type = sensor_type - self._device = device self._extra = None - self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[3]) - self._kind = SENSOR_TYPES.get(self._sensor_type)[4] + self._icon = "mdi:{}".format(SENSOR_TYPES.get(sensor_type)[3]) + self._kind = SENSOR_TYPES.get(sensor_type)[4] self._name = "{0} {1}".format( - self._device.name, SENSOR_TYPES.get(self._sensor_type)[0] + self._device.name, SENSOR_TYPES.get(sensor_type)[0] ) - self._state = None - self._unique_id = f"{self._device.id}-{self._sensor_type}" - self._disp_disconnect = None - self._disp_disconnect_health = None - - async def async_added_to_hass(self): - """Register callbacks.""" - self._disp_disconnect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RING, self._update_callback - ) - if self._sensor_type not in ("wifi_signal_category", "wifi_signal_strength"): - return - - self._disp_disconnect_health = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HEALTH_RING, self._update_callback - ) - await self.hass.data[DATA_HEALTH_DATA_TRACKER].track_device( - self._config_entry_id, self._device - ) - # Write the state, it was not available when doing initial update. - if self._sensor_type == "wifi_signal_category": - self._state = self._device.wifi_signal_category - - if self._sensor_type == "wifi_signal_strength": - self._state = self._device.wifi_signal_strength - - async def async_will_remove_from_hass(self): - """Disconnect callbacks.""" - if self._disp_disconnect: - self._disp_disconnect() - self._disp_disconnect = None - - if self._disp_disconnect_health: - self._disp_disconnect_health() - self._disp_disconnect_health = None - - if self._sensor_type not in ("wifi_signal_category", "wifi_signal_strength"): - return - - self.hass.data[DATA_HEALTH_DATA_TRACKER].untrack_device( - self._config_entry_id, self._device - ) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) + self._unique_id = f"{device.id}-{sensor_type}" @property def should_poll(self): @@ -180,7 +69,11 @@ class RingSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self._state + if self._sensor_type == "volume": + return self._device.volume + + if self._sensor_type == "battery": + return self._device.battery_life @property def unique_id(self): @@ -192,37 +85,12 @@ class RingSensor(Entity): """Return sensor device class.""" return SENSOR_TYPES[self._sensor_type][5] - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "model": self._device.model, - "manufacturer": "Ring", - } - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION - - if self._extra and self._sensor_type.startswith("last_"): - attrs["created_at"] = self._extra["created_at"] - attrs["answered"] = self._extra["answered"] - attrs["recording_status"] = self._extra["recording"]["status"] - attrs["category"] = self._extra["kind"] - - return attrs - @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery" and self._state is not None: + if self._sensor_type == "battery" and self._device.battery_life is not None: return icon_for_battery_level( - battery_level=int(self._state), charging=False + battery_level=self._device.battery_life, charging=False ) return self._icon @@ -231,34 +99,168 @@ class RingSensor(Entity): """Return the units of measurement.""" return SENSOR_TYPES.get(self._sensor_type)[2] - async def async_update(self): - """Get the latest data and updates the state.""" - _LOGGER.debug("Updating data from %s sensor", self._name) - if self._sensor_type == "volume": - self._state = self._device.volume +class HealthDataRingSensor(RingSensor): + """Ring sensor that relies on health data.""" - if self._sensor_type == "battery": - self._state = self._device.battery_life + async def async_added_to_hass(self): + """Register callbacks.""" + await super().async_added_to_hass() - if self._sensor_type.startswith("last_"): - history = await self.hass.data[DATA_HISTORY].async_get_history( - self._config_entry_id, self._device - ) + await self.ring_objects["health_data"].async_track_device( + self._device, self._health_update_callback + ) - found = None - for entry in history: + async def async_will_remove_from_hass(self): + """Disconnect callbacks.""" + await super().async_will_remove_from_hass() + + self.ring_objects["health_data"].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 state(self): + """Return the state of the sensor.""" + if self._sensor_type == "wifi_signal_category": + return self._device.wifi_signal_category + + if self._sensor_type == "wifi_signal_strength": + return self._device.wifi_signal_strength + + +class HistoryRingSensor(RingSensor): + """Ring sensor that relies on history data.""" + + _latest_event = None + + async def async_added_to_hass(self): + """Register callbacks.""" + await super().async_added_to_hass() + + await self.ring_objects["history_data"].async_track_device( + self._device, self._history_update_callback + ) + + async def async_will_remove_from_hass(self): + """Disconnect callbacks.""" + await super().async_will_remove_from_hass() + + self.ring_objects["history_data"].async_untrack_device( + self._device, self._history_update_callback + ) + + @callback + def _history_update_callback(self, history_data): + """Call update method.""" + if not history_data: + return + + found = None + if self._kind is None: + found = history_data[0] + else: + for entry in history_data: if entry["kind"] == self._kind: found = entry break - if found: - self._extra = found - created_at = found["created_at"] - self._state = created_at.isoformat() + if not found: + return - if self._sensor_type == "wifi_signal_category": - self._state = self._device.wifi_signal_category + self._latest_event = found + self.async_write_ha_state() - if self._sensor_type == "wifi_signal_strength": - self._state = self._device.wifi_signal_strength + @property + def state(self): + """Return the state of the sensor.""" + if self._latest_event is None: + return None + + return self._latest_event["created_at"].isoformat() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = super().device_state_attributes + + if self._latest_event: + attrs["created_at"] = self._latest_event["created_at"] + attrs["answered"] = self._latest_event["answered"] + attrs["recording_status"] = self._latest_event["recording"]["status"] + attrs["category"] = self._latest_event["kind"] + + return attrs + + +# Sensor types: Name, category, units, icon, kind, device_class, class +SENSOR_TYPES = { + "battery": [ + "Battery", + ["doorbots", "authorized_doorbots", "stickup_cams"], + "%", + None, + None, + "battery", + RingSensor, + ], + "last_activity": [ + "Last Activity", + ["doorbots", "authorized_doorbots", "stickup_cams"], + None, + "history", + None, + "timestamp", + HistoryRingSensor, + ], + "last_ding": [ + "Last Ding", + ["doorbots", "authorized_doorbots"], + None, + "history", + "ding", + "timestamp", + HistoryRingSensor, + ], + "last_motion": [ + "Last Motion", + ["doorbots", "authorized_doorbots", "stickup_cams"], + None, + "history", + "motion", + "timestamp", + HistoryRingSensor, + ], + "volume": [ + "Volume", + ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + None, + "bell-ring", + None, + None, + RingSensor, + ], + "wifi_signal_category": [ + "WiFi Signal Category", + ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + None, + "wifi", + None, + None, + HealthDataRingSensor, + ], + "wifi_signal_strength": [ + "WiFi Signal Strength", + ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + "dBm", + "wifi", + None, + "signal_strength", + HealthDataRingSensor, + ], +} diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 06f81732784..65eed83d98e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -4,10 +4,10 @@ import logging from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.dt as dt_util -from . import DOMAIN, SIGNAL_UPDATE_RING +from . import DOMAIN +from .entity import RingEntityMixin _LOGGER = logging.getLogger(__name__) @@ -24,49 +24,24 @@ SKIP_UPDATES_DELAY = timedelta(seconds=5) async def async_setup_entry(hass, config_entry, async_add_entities): """Create the switches for the Ring devices.""" - ring = hass.data[DOMAIN][config_entry.entry_id] - devices = ring.devices() + devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] switches = [] for device in devices["stickup_cams"]: if device.has_capability("siren"): - switches.append(SirenSwitch(device)) + switches.append(SirenSwitch(config_entry.entry_id, device)) - async_add_entities(switches, True) + async_add_entities(switches) -class BaseRingSwitch(SwitchDevice): +class BaseRingSwitch(RingEntityMixin, SwitchDevice): """Represents a switch for controlling an aspect of a ring device.""" - def __init__(self, device, device_type): + def __init__(self, config_entry_id, device, device_type): """Initialize the switch.""" - self._device = device + super().__init__(config_entry_id, device) self._device_type = device_type self._unique_id = f"{self._device.id}-{self._device_type}" - self._disp_disconnect = None - - async def async_added_to_hass(self): - """Register callbacks.""" - self._disp_disconnect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RING, self._update_callback - ) - - async def async_will_remove_from_hass(self): - """Disconnect callbacks.""" - if self._disp_disconnect: - self._disp_disconnect() - self._disp_disconnect = None - - @callback - def _update_callback(self): - """Call update method.""" - _LOGGER.debug("Updating Ring switch %s (callback)", self.name) - self.async_schedule_update_ha_state(True) - - @property - def should_poll(self): - """Update controlled via the hub.""" - return False @property def name(self): @@ -78,25 +53,24 @@ class BaseRingSwitch(SwitchDevice): """Return a unique ID.""" return self._unique_id - @property - def device_info(self): - """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "model": self._device.model, - "manufacturer": "Ring", - } - class SirenSwitch(BaseRingSwitch): """Creates a switch to turn the ring cameras siren on and off.""" - def __init__(self, device): + def __init__(self, config_entry_id, device): """Initialize the switch for a device with a siren.""" - super().__init__(device, "siren") + super().__init__(config_entry_id, device, "siren") self._no_updates_until = dt_util.utcnow() - self._siren_on = False + self._siren_on = device.siren > 0 + + @callback + def _update_callback(self): + """Call update method.""" + if self._no_updates_until > dt_util.utcnow(): + return + + self._siren_on = self._device.siren > 0 + self.async_write_ha_state() def _set_switch(self, new_state): """Update switch state, and causes Home Assistant to correctly update.""" @@ -122,10 +96,3 @@ class SirenSwitch(BaseRingSwitch): def icon(self): """Return the icon.""" return SIREN_ICON - - async def async_update(self): - """Update state of the siren.""" - if self._no_updates_until > dt_util.utcnow(): - _LOGGER.debug("Skipping update...") - return - self._siren_on = self._device.siren > 0 diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 8615138d56e..0b73c739503 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,4 +1,5 @@ """The tests for the Ring binary sensor platform.""" +from time import time from unittest.mock import patch from .common import setup_platform @@ -8,7 +9,15 @@ async def test_binary_sensor(hass, requests_mock): """Test the Ring binary sensors.""" with patch( "ring_doorbell.Ring.active_alerts", - return_value=[{"kind": "motion", "doorbot_id": 987654}], + return_value=[ + { + "kind": "motion", + "doorbot_id": 987654, + "state": "ringing", + "now": time(), + "expires_in": 180, + } + ], ): await setup_platform(hass, "binary_sensor")