Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ homeassistant/components/rainmachine/* @bachya
homeassistant/components/random/* @fabaff
homeassistant/components/repetier/* @MTrab
homeassistant/components/rfxtrx/* @danielhiversen
homeassistant/components/ring/* @balloob
homeassistant/components/rmvtransport/* @cgtobi
homeassistant/components/roomba/* @pschmitt
homeassistant/components/saj/* @fredericvl
Expand Down
169 changes: 126 additions & 43 deletions homeassistant/components/ring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
from functools import partial
import logging
from pathlib import Path
from time import time

from ring_doorbell import Auth, Ring
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import track_time_interval
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

_LOGGER = logging.getLogger(__name__)
Expand All @@ -22,14 +24,14 @@
NOTIFICATION_ID = "ring_notification"
NOTIFICATION_TITLE = "Ring Setup"

DATA_RING_DOORBELLS = "ring_doorbells"
DATA_RING_STICKUP_CAMS = "ring_stickup_cams"
DATA_RING_CHIMES = "ring_chimes"
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)

Expand Down Expand Up @@ -88,51 +90,42 @@ def token_updater(token):
),
).result()

auth = Auth(entry.data["token"], token_updater)
auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater)
ring = Ring(auth)

await hass.async_add_executor_job(finish_setup_entry, hass, ring)
await hass.async_add_executor_job(ring.update_data)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ring

for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)

return True


def finish_setup_entry(hass, ring):
"""Finish setting up entry."""
devices = ring.devices
hass.data[DATA_RING_CHIMES] = chimes = devices["chimes"]
hass.data[DATA_RING_DOORBELLS] = doorbells = devices["doorbells"]
hass.data[DATA_RING_STICKUP_CAMS] = stickup_cams = devices["stickup_cams"]

ring_devices = chimes + doorbells + stickup_cams

def service_hub_refresh(service):
hub_refresh()

def timer_hub_refresh(event_time):
hub_refresh()

def hub_refresh():
"""Call ring to refresh information."""
_LOGGER.debug("Updating Ring Hub component")

for camera in ring_devices:
_LOGGER.debug("Updating camera %s", camera.name)
camera.update()
if hass.services.has_service(DOMAIN, "update"):
return True

dispatcher_send(hass, SIGNAL_UPDATE_RING)
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)

# register service
hass.services.register(DOMAIN, "update", service_hub_refresh)
hass.services.async_register(DOMAIN, "update", refresh_all)

# register scan interval for ring
hass.data[DATA_TRACK_INTERVAL] = track_time_interval(
hass, timer_hub_refresh, SCAN_INTERVAL
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


async def async_unload_entry(hass, entry):
Expand All @@ -148,13 +141,103 @@ async def async_unload_entry(hass, entry):
if not unload_ok:
return False

await hass.async_add_executor_job(hass.data[DATA_TRACK_INTERVAL])
hass.data[DOMAIN].pop(entry.entry_id)

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)
hass.services.async_remove(DOMAIN, "update")

hass.data.pop(DATA_RING_DOORBELLS)
hass.data.pop(DATA_RING_STICKUP_CAMS)
hass.data.pop(DATA_RING_CHIMES)
hass.data.pop(DATA_TRACK_INTERVAL)
return True


class HealthDataUpdater:
"""Data storage for health data."""

def __init__(self, hass):
"""Track devices that need health data updated."""
self.hass = hass
self.devices = {}
self._unsub_interval = None

async def track_device(self, config_entry_id, device):
"""Track a device."""
if not self.devices:
self._unsub_interval = async_track_time_interval(
self.hass, self.refresh_all, SCAN_INTERVAL
)

key = (config_entry_id, device.device_id)

if key not in self.devices:
self.devices[key] = {
"device": device,
"count": 1,
}
else:
self.devices[key]["count"] += 1

await self.hass.async_add_executor_job(device.update_health_data)

@callback
def untrack_device(self, config_entry_id, device):
"""Untrack a device."""
key = (config_entry_id, device.device_id)
self.devices[key]["count"] -= 1

if self.devices[key]["count"] == 0:
self.devices.pop(key)

if not self.devices:
self._unsub_interval()
self._unsub_interval = None

def refresh_all(self, _):
"""Refresh all registered devices."""
for info in self.devices.values():
info["device"].update_health_data()

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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this better than using a dict cache and our Throttle helper to throttle updating the cache?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, because with the Throttle decorator, if a call is in process, it will return None instead of waiting for the call to finish.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So at worst we would then get 10 second stale data?

What kind of data is this?

Copy link
Copy Markdown
Member Author

@balloob balloob Jan 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last 10 events for a device.

Throttle returns None, not the data. The implementation would become then data.update(); handle(data.data)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that if this approach seems to work, we might want to turn that into a generic asyncio throttle function. We do a similar thing in Hue.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still not clear to me why a central update and cache with signals telling the entities to update would be worse than this central update with a timed cache where entities pull data, in this situation.

The entities per device that are involved are fixed, except for the camera where only cameras that have subscription are added. So there won't be less or more entities per device pulling data except for the camera case. Maybe the camera entity is the reason?

My point is, that if we know what devices we have and nothing about the features of those devices changes, we should be able to update all devices regardless of platforms.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my bad. I thought you asked why not use X, instead of how does it work with the existing dispatch update that Ring uses.

So I don't have a good reason. And it is indeed kinda complicated now. We now have a health tracker that has an opt-in signal. We could have used the same approach for history. hmm.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to rewrite it ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's ok like this. I mostly want to understand the reasons for the different designs so I know when to recommend what approach. And I like as few choices as possible for my recommendations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about my last comment tone. I meant to say want ME to rewrite it.

I thought about it more and I'll do one more pass cleaning up data fetching. I can do better here.

"""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 unload_ok
return data
90 changes: 54 additions & 36 deletions homeassistant/components/ring/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,78 @@

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, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS, DOMAIN
from . import ATTRIBUTION, DOMAIN, SIGNAL_UPDATE_RING

_LOGGER = logging.getLogger(__name__)

SCAN_INTERVAL = timedelta(seconds=10)

# Sensor types: Name, category, device_class
SENSOR_TYPES = {
"ding": ["Ding", ["doorbell"], "occupancy"],
"motion": ["Motion", ["doorbell", "stickup_cams"], "motion"],
"ding": ["Ding", ["doorbots", "authorized_doorbots"], "occupancy"],
"motion": ["Motion", ["doorbots", "authorized_doorbots", "stickup_cams"], "motion"],
}


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Ring binary sensors from a config entry."""
ring_doorbells = hass.data[DATA_RING_DOORBELLS]
ring_stickup_cams = hass.data[DATA_RING_STICKUP_CAMS]
ring = hass.data[DOMAIN][config_entry.entry_id]
devices = ring.devices()

sensors = []
for device in ring_doorbells: # ring.doorbells is doing I/O
for sensor_type in SENSOR_TYPES:
if "doorbell" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingBinarySensor(hass, device, sensor_type))

for device in ring_stickup_cams: # ring.stickup_cams is doing I/O
for device_type in ("doorbots", "authorized_doorbots", "stickup_cams"):
for sensor_type in SENSOR_TYPES:
if "stickup_cams" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingBinarySensor(hass, device, sensor_type))
if device_type not in SENSOR_TYPES[sensor_type][1]:
continue

for device in devices[device_type]:
sensors.append(RingBinarySensor(ring, device, sensor_type))

async_add_entities(sensors, True)


class RingBinarySensor(BinarySensorDevice):
"""A binary sensor implementation for Ring device."""

def __init__(self, hass, data, sensor_type):
def __init__(self, ring, device, sensor_type):
"""Initialize a sensor for Ring device."""
super().__init__()
self._sensor_type = sensor_type
self._data = data
self._ring = ring
self._device = device
self._name = "{0} {1}".format(
self._data.name, SENSOR_TYPES.get(self._sensor_type)[0]
self._device.name, SENSOR_TYPES.get(self._sensor_type)[0]
)
self._device_class = SENSOR_TYPES.get(self._sensor_type)[2]
self._state = None
self._unique_id = f"{self._data.id}-{self._sensor_type}"
self._unique_id = f"{self._device.id}-{self._sensor_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."""
self.async_schedule_update_ha_state(True)
_LOGGER.debug("Updating Ring binary sensor %s (callback)", self.name)

@property
def should_poll(self):
"""Return False, updates are controlled via the hub."""
return False

@property
def name(self):
Expand All @@ -76,10 +101,9 @@ def unique_id(self):
def device_info(self):
"""Return device info."""
return {
"identifiers": {(DOMAIN, self._data.id)},
"sw_version": self._data.firmware,
"name": self._data.name,
"model": self._data.kind,
"identifiers": {(DOMAIN, self._device.device_id)},
"name": self._device.name,
"model": self._device.model,
"manufacturer": "Ring",
}

Expand All @@ -89,22 +113,16 @@ def device_state_attributes(self):
attrs = {}
attrs[ATTR_ATTRIBUTION] = ATTRIBUTION

attrs["timezone"] = self._data.timezone

if self._data.alert and self._data.alert_expires_at:
attrs["expires_at"] = self._data.alert_expires_at
attrs["state"] = self._data.alert.get("state")
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")

return attrs

def update(self):
async def async_update(self):
"""Get the latest data and updates the state."""
self._data.check_alerts()

if self._data.alert:
if self._sensor_type == self._data.alert.get(
"kind"
) and self._data.account_id == self._data.alert.get("doorbot_id"):
self._state = True
else:
self._state = False
self._state = any(
alert["kind"] == self._sensor_type
and alert["doorbot_id"] == self._device.id
for alert in self._ring.active_alerts()
)
Loading