Refactor Ring data handling (#30777)

* Refactor Ring data handling

* Add async_ to methods
This commit is contained in:
Paulus Schoutsen 2020-01-15 08:10:42 -08:00 committed by GitHub
parent de26108b23
commit 1e82813c3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 514 additions and 482 deletions

View File

@ -4,16 +4,16 @@ from datetime import timedelta
from functools import partial from functools import partial
import logging import logging
from pathlib import Path from pathlib import Path
from time import time from typing import Optional
from oauthlib.oauth2 import AccessDeniedError
from ring_doorbell import Auth, Ring from ring_doorbell import Auth, Ring
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ 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 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.helpers.event import async_track_time_interval
from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.async_ import run_callback_threadsafe
@ -24,16 +24,8 @@ ATTRIBUTION = "Data provided by Ring.com"
NOTIFICATION_ID = "ring_notification" NOTIFICATION_ID = "ring_notification"
NOTIFICATION_TITLE = "Ring Setup" NOTIFICATION_TITLE = "Ring Setup"
DATA_HISTORY = "ring_history"
DATA_HEALTH_DATA_TRACKER = "ring_health_data"
DATA_TRACK_INTERVAL = "ring_track_interval"
DOMAIN = "ring" DOMAIN = "ring"
DEFAULT_ENTITY_NAMESPACE = "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") 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) auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater)
ring = Ring(auth) 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: for component in PLATFORMS:
hass.async_create_task( hass.async_create_task(
@ -105,25 +124,16 @@ async def async_setup_entry(hass, entry):
if hass.services.has_service(DOMAIN, "update"): if hass.services.has_service(DOMAIN, "update"):
return True return True
async def refresh_all(_): async def async_refresh_all(_):
"""Refresh all ring accounts.""" """Refresh all ring data."""
await asyncio.gather( for info in hass.data[DOMAIN].values():
*[ await info["device_data"].async_refresh_all()
hass.async_add_executor_job(api.update_data) await info["dings_data"].async_refresh_all()
for api in hass.data[DOMAIN].values() await hass.async_add_executor_job(info["history_data"].refresh_all)
] await hass.async_add_executor_job(info["health_data"].refresh_all)
)
async_dispatcher_send(hass, SIGNAL_UPDATE_RING)
# register service # register service
hass.services.async_register(DOMAIN, "update", refresh_all) hass.services.async_register(DOMAIN, "update", async_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)
return True return True
@ -146,98 +156,141 @@ async def async_unload_entry(hass, entry):
if len(hass.data[DOMAIN]) != 0: if len(hass.data[DOMAIN]) != 0:
return True return True
# Last entry unloaded, clean up # Last entry unloaded, clean up service
hass.data.pop(DATA_TRACK_INTERVAL)()
hass.data.pop(DATA_HEALTH_DATA_TRACKER)
hass.data.pop(DATA_HISTORY)
hass.services.async_remove(DOMAIN, "update") hass.services.async_remove(DOMAIN, "update")
return True return True
class HealthDataUpdater: class GlobalDataUpdater:
"""Data storage for health data.""" """Data storage for single API endpoint."""
def __init__(self, hass): def __init__(
"""Track devices that need health data updated.""" self,
hass: HomeAssistant,
config_entry_id: str,
ring: Ring,
update_method: str,
update_interval: timedelta,
):
"""Initialize global data updater."""
self.hass = hass 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.devices = {}
self._unsub_interval = None 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.""" """Track a device."""
if not self.devices: if not self.devices:
self._unsub_interval = async_track_time_interval( 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 device.device_id not in self.devices:
self.devices[device.device_id] = {
if key not in self.devices:
self.devices[key] = {
"device": device, "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: 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 @callback
def untrack_device(self, config_entry_id, device): def async_untrack_device(self, device, update_callback):
"""Untrack a device.""" """Untrack a device."""
key = (config_entry_id, device.device_id) self.devices[device.device_id]["update_callbacks"].remove(update_callback)
self.devices[key]["count"] -= 1
if self.devices[key]["count"] == 0: if not self.devices[device.device_id]["update_callbacks"]:
self.devices.pop(key) self.devices.pop(device.device_id)
if not self.devices: if not self.devices:
self._unsub_interval() self._unsub_interval()
self._unsub_interval = None self._unsub_interval = None
def refresh_all(self, _): def refresh_all(self, _=None):
"""Refresh all registered devices.""" """Refresh all registered devices."""
for info in self.devices.values(): 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) for update_callback in info["update_callbacks"]:
self.hass.loop.call_soon_threadsafe(update_callback, data)
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

View File

@ -1,13 +1,12 @@
"""This component provides HA sensor support for Ring Door Bell/Chimes.""" """This component provides HA sensor support for Ring Door Bell/Chimes."""
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import callback 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__) _LOGGER = logging.getLogger(__name__)
@ -22,8 +21,8 @@ SENSOR_TYPES = {
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Ring binary sensors from a config entry.""" """Set up the Ring binary sensors from a config entry."""
ring = hass.data[DOMAIN][config_entry.entry_id] ring = hass.data[DOMAIN][config_entry.entry_id]["api"]
devices = ring.devices() devices = hass.data[DOMAIN][config_entry.entry_id]["devices"]
sensors = [] sensors = []
@ -33,49 +32,62 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
continue continue
for device in devices[device_type]: 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.""" """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.""" """Initialize a sensor for Ring device."""
self._sensor_type = sensor_type super().__init__(config_entry_id, device)
self._ring = ring self._ring = ring
self._device = device self._sensor_type = sensor_type
self._name = "{0} {1}".format( 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._state = None
self._unique_id = f"{self._device.id}-{self._sensor_type}" self._unique_id = f"{device.id}-{sensor_type}"
self._disp_disconnect = None self._update_alert()
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
self._disp_disconnect = async_dispatcher_connect( await super().async_added_to_hass()
self.hass, SIGNAL_UPDATE_RING, self._update_callback self.ring_objects["dings_data"].async_add_listener(self._dings_update_callback)
) self._dings_update_callback()
async def async_will_remove_from_hass(self): async def async_will_remove_from_hass(self):
"""Disconnect callbacks.""" """Disconnect callbacks."""
if self._disp_disconnect: await super().async_will_remove_from_hass()
self._disp_disconnect() self.ring_objects["dings_data"].async_remove_listener(
self._disp_disconnect = None self._dings_update_callback
)
@callback @callback
def _update_callback(self): def _dings_update_callback(self):
"""Call update method.""" """Call update method."""
self.async_schedule_update_ha_state(True) self._update_alert()
_LOGGER.debug("Updating Ring binary sensor %s (callback)", self.name) self.async_write_ha_state()
@property @callback
def should_poll(self): def _update_alert(self):
"""Return False, updates are controlled via the hub.""" """Update active alert."""
return False 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 @property
def name(self): def name(self):
@ -85,7 +97,7 @@ class RingBinarySensor(BinarySensorDevice):
@property @property
def is_on(self): def is_on(self):
"""Return True if the binary sensor is on.""" """Return True if the binary sensor is on."""
return self._state return self._active_alert is not None
@property @property
def device_class(self): def device_class(self):
@ -97,32 +109,17 @@ class RingBinarySensor(BinarySensorDevice):
"""Return a unique ID.""" """Return a unique ID."""
return self._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 @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the state attributes.""" """Return the state attributes."""
attrs = {} attrs = super().device_state_attributes
attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
if self._device.alert and self._device.alert_expires_at: if self._active_alert is None:
attrs["expires_at"] = self._device.alert_expires_at return attrs
attrs["state"] = self._device.alert.get("state")
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 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()
)

View File

@ -12,10 +12,10 @@ from homeassistant.components.ffmpeg import DATA_FFMPEG
from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream 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 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) 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a Ring Door Bell and StickUp Camera.""" """Set up a Ring Door Bell and StickUp Camera."""
ring = hass.data[DOMAIN][config_entry.entry_id] devices = hass.data[DOMAIN][config_entry.entry_id]["devices"]
devices = ring.devices()
cams = [] cams = []
for camera in chain( 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)) 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.""" """An implementation of a Ring Door Bell camera."""
def __init__(self, config_entry_id, ffmpeg, device): def __init__(self, config_entry_id, ffmpeg, device):
"""Initialize a Ring Door Bell camera.""" """Initialize a Ring Door Bell camera."""
super().__init__() super().__init__(config_entry_id, device)
self._config_entry_id = config_entry_id
self._device = device
self._name = self._device.name self._name = self._device.name
self._ffmpeg = ffmpeg self._ffmpeg = ffmpeg
self._last_event = None
self._last_video_id = None self._last_video_id = None
self._video_url = None self._video_url = None
self._utcnow = dt_util.utcnow() self._utcnow = dt_util.utcnow()
self._expires_at = self._utcnow - FORCE_REFRESH_INTERVAL self._expires_at = self._utcnow - FORCE_REFRESH_INTERVAL
self._disp_disconnect = None
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
self._disp_disconnect = async_dispatcher_connect( await super().async_added_to_hass()
self.hass, SIGNAL_UPDATE_RING, self._update_callback
await self.ring_objects["history_data"].async_track_device(
self._device, self._history_update_callback
) )
async def async_will_remove_from_hass(self): async def async_will_remove_from_hass(self):
"""Disconnect callbacks.""" """Disconnect callbacks."""
if self._disp_disconnect: await super().async_will_remove_from_hass()
self._disp_disconnect()
self._disp_disconnect = None self.ring_objects["history_data"].async_untrack_device(
self._device, self._history_update_callback
)
@callback @callback
def _update_callback(self): def _history_update_callback(self, history_data):
"""Call update method.""" """Call update method."""
self.async_schedule_update_ha_state(True) if history_data:
_LOGGER.debug("Updating Ring camera %s (callback)", self.name) 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 @property
def name(self): def name(self):
@ -83,16 +92,6 @@ class RingCam(Camera):
"""Return a unique ID.""" """Return a unique ID."""
return self._device.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 @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the state attributes.""" """Return the state attributes."""
@ -104,7 +103,6 @@ class RingCam(Camera):
async def async_camera_image(self): async def async_camera_image(self):
"""Return a still image response from the camera.""" """Return a still image response from the camera."""
ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop)
if self._video_url is None: if self._video_url is None:
@ -136,33 +134,23 @@ class RingCam(Camera):
async def async_update(self): async def async_update(self):
"""Update camera entity and refresh attributes.""" """Update camera entity and refresh attributes."""
_LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url") if self._last_event is None:
self._utcnow = dt_util.utcnow()
data = await self.hass.data[DATA_HISTORY].async_get_history(
self._config_entry_id, self._device
)
if not data:
return return
last_event = data[0] if self._last_event["recording"]["status"] != "ready":
last_recording_id = last_event["id"] return
video_status = last_event["recording"]["status"]
if video_status == "ready" and ( if (
self._last_video_id != last_recording_id or self._utcnow >= self._expires_at self._last_video_id == self._last_event["id"]
and self._utcnow <= self._expires_at
): ):
return
video_url = await self.hass.async_add_executor_job( video_url = await self.hass.async_add_executor_job(
self._device.recording_url, last_recording_id self._device.recording_url, self._last_event["id"]
) )
if video_url: if video_url:
_LOGGER.debug("Ring DoorBell properties refreshed") self._last_video_id = self._last_event["id"]
self._video_url = video_url
# update attributes if new video or if URL has expired self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
self._last_video_id = last_recording_id
self._video_url = video_url
self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow

View File

@ -39,9 +39,6 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Handle the initial step.""" """Handle the initial step."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")
errors = {} errors = {}
if user_input is not None: if user_input is not None:
try: try:

View File

@ -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",
}

View File

@ -4,10 +4,10 @@ import logging
from homeassistant.components.light import Light from homeassistant.components.light import Light
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from . import DOMAIN, SIGNAL_UPDATE_RING from . import DOMAIN
from .entity import RingEntityMixin
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -25,51 +25,35 @@ OFF_STATE = "off"
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the lights for the Ring devices.""" """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 = [] lights = []
for device in devices["stickup_cams"]: for device in devices["stickup_cams"]:
if device.has_capability("light"): if device.has_capability("light"):
lights.append(RingLight(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.""" """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.""" """Initialize the light."""
self._device = device super().__init__(config_entry_id, device)
self._unique_id = self._device.id self._unique_id = device.id
self._light_on = False self._light_on = device.lights == ON_STATE
self._no_updates_until = dt_util.utcnow() 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 @callback
def _update_callback(self): def _update_callback(self):
"""Call update method.""" """Call update method."""
_LOGGER.debug("Updating Ring light %s (callback)", self.name) if self._no_updates_until > dt_util.utcnow():
self.async_schedule_update_ha_state(True) return
@property self._light_on = self._device.lights == ON_STATE
def should_poll(self): self.async_write_ha_state()
"""Update controlled via the hub."""
return False
@property @property
def name(self): def name(self):
@ -86,22 +70,12 @@ class RingLight(Light):
"""If the switch is currently on or off.""" """If the switch is currently on or off."""
return self._light_on 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): def _set_light(self, new_state):
"""Update light state, and causes Home Assistant to correctly update.""" """Update light state, and causes Home Assistant to correctly update."""
self._device.lights = new_state self._device.lights = new_state
self._light_on = new_state == ON_STATE self._light_on = new_state == ON_STATE
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
self.async_schedule_update_ha_state(True) self.async_schedule_update_ha_state()
def turn_on(self, **kwargs): def turn_on(self, **kwargs):
"""Turn the light on for 30 seconds.""" """Turn the light on for 30 seconds."""
@ -110,11 +84,3 @@ class RingLight(Light):
def turn_off(self, **kwargs): def turn_off(self, **kwargs):
"""Turn the light off.""" """Turn the light off."""
self._set_light(OFF_STATE) 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

View File

@ -1,88 +1,20 @@
"""This component provides HA sensor support for Ring Door Bell/Chimes.""" """This component provides HA sensor support for Ring Door Bell/Chimes."""
import logging import logging
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.icon import icon_for_battery_level
from . import ( from . import DOMAIN
ATTRIBUTION, from .entity import RingEntityMixin
DATA_HEALTH_DATA_TRACKER,
DATA_HISTORY,
DOMAIN,
SIGNAL_UPDATE_HEALTH_RING,
SIGNAL_UPDATE_RING,
)
_LOGGER = logging.getLogger(__name__) _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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a sensor for a Ring device.""" """Set up a sensor for a Ring device."""
ring = hass.data[DOMAIN][config_entry.entry_id] devices = hass.data[DOMAIN][config_entry.entry_id]["devices"]
devices = ring.devices()
# Makes a ton of requests. We will make this a config entry option in the future # Makes a ton of requests. We will make this a config entry option in the future
wifi_enabled = False 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: if device_type == "battery" and device.battery_life is None:
continue 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.""" """A sensor implementation for Ring device."""
def __init__(self, config_entry_id, device, sensor_type): def __init__(self, config_entry_id, device, sensor_type):
"""Initialize a sensor for Ring device.""" """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._sensor_type = sensor_type
self._device = device
self._extra = None self._extra = None
self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[3]) self._icon = "mdi:{}".format(SENSOR_TYPES.get(sensor_type)[3])
self._kind = SENSOR_TYPES.get(self._sensor_type)[4] self._kind = SENSOR_TYPES.get(sensor_type)[4]
self._name = "{0} {1}".format( 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"{device.id}-{sensor_type}"
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)
@property @property
def should_poll(self): def should_poll(self):
@ -180,7 +69,11 @@ class RingSensor(Entity):
@property @property
def state(self): def state(self):
"""Return the state of the sensor.""" """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 @property
def unique_id(self): def unique_id(self):
@ -192,37 +85,12 @@ class RingSensor(Entity):
"""Return sensor device class.""" """Return sensor device class."""
return SENSOR_TYPES[self._sensor_type][5] 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 @property
def icon(self): def icon(self):
"""Icon to use in the frontend, if any.""" """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( return icon_for_battery_level(
battery_level=int(self._state), charging=False battery_level=self._device.battery_life, charging=False
) )
return self._icon return self._icon
@ -231,34 +99,168 @@ class RingSensor(Entity):
"""Return the units of measurement.""" """Return the units of measurement."""
return SENSOR_TYPES.get(self._sensor_type)[2] 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": class HealthDataRingSensor(RingSensor):
self._state = self._device.volume """Ring sensor that relies on health data."""
if self._sensor_type == "battery": async def async_added_to_hass(self):
self._state = self._device.battery_life """Register callbacks."""
await super().async_added_to_hass()
if self._sensor_type.startswith("last_"): await self.ring_objects["health_data"].async_track_device(
history = await self.hass.data[DATA_HISTORY].async_get_history( self._device, self._health_update_callback
self._config_entry_id, self._device )
)
found = None async def async_will_remove_from_hass(self):
for entry in history: """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: if entry["kind"] == self._kind:
found = entry found = entry
break break
if found: if not found:
self._extra = found return
created_at = found["created_at"]
self._state = created_at.isoformat()
if self._sensor_type == "wifi_signal_category": self._latest_event = found
self._state = self._device.wifi_signal_category self.async_write_ha_state()
if self._sensor_type == "wifi_signal_strength": @property
self._state = self._device.wifi_signal_strength 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,
],
}

View File

@ -4,10 +4,10 @@ import logging
from homeassistant.components.switch import SwitchDevice from homeassistant.components.switch import SwitchDevice
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from . import DOMAIN, SIGNAL_UPDATE_RING from . import DOMAIN
from .entity import RingEntityMixin
_LOGGER = logging.getLogger(__name__) _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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the switches for the Ring devices.""" """Create the switches for the Ring devices."""
ring = hass.data[DOMAIN][config_entry.entry_id] devices = hass.data[DOMAIN][config_entry.entry_id]["devices"]
devices = ring.devices()
switches = [] switches = []
for device in devices["stickup_cams"]: for device in devices["stickup_cams"]:
if device.has_capability("siren"): if device.has_capability("siren"):
switches.append(SirenSwitch(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.""" """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.""" """Initialize the switch."""
self._device = device super().__init__(config_entry_id, device)
self._device_type = device_type self._device_type = device_type
self._unique_id = f"{self._device.id}-{self._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 @property
def name(self): def name(self):
@ -78,25 +53,24 @@ class BaseRingSwitch(SwitchDevice):
"""Return a unique ID.""" """Return a unique ID."""
return self._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): class SirenSwitch(BaseRingSwitch):
"""Creates a switch to turn the ring cameras siren on and off.""" """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.""" """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._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): def _set_switch(self, new_state):
"""Update switch state, and causes Home Assistant to correctly update.""" """Update switch state, and causes Home Assistant to correctly update."""
@ -122,10 +96,3 @@ class SirenSwitch(BaseRingSwitch):
def icon(self): def icon(self):
"""Return the icon.""" """Return the icon."""
return SIREN_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

View File

@ -1,4 +1,5 @@
"""The tests for the Ring binary sensor platform.""" """The tests for the Ring binary sensor platform."""
from time import time
from unittest.mock import patch from unittest.mock import patch
from .common import setup_platform from .common import setup_platform
@ -8,7 +9,15 @@ async def test_binary_sensor(hass, requests_mock):
"""Test the Ring binary sensors.""" """Test the Ring binary sensors."""
with patch( with patch(
"ring_doorbell.Ring.active_alerts", "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") await setup_platform(hass, "binary_sensor")