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

View File

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

View File

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

View File

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

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.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

View File

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

View File

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

View File

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