Skip to content

Commit

Permalink
Add Ring config flow (#30564)
Browse files Browse the repository at this point in the history
* Add Ring config flow

* Address comments + migrate platforms to config entry

* Migrate camera too

* Address comments

* Fix order config flows

* setup -> async_setup
  • Loading branch information
balloob committed Jan 10, 2020
1 parent e58b41e commit 0b3b589
Show file tree
Hide file tree
Showing 17 changed files with 436 additions and 147 deletions.
28 changes: 28 additions & 0 deletions homeassistant/components/ring/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"2fa": {
"data": {
"2fa": "Two-factor code"
},
"title": "Enter two-factor authentication"
},
"user": {
"data": {
"password": "Password",
"username": "Username"
},
"title": "Connect to the device"
}
},
"title": "Ring"
}
}
112 changes: 91 additions & 21 deletions homeassistant/components/ring/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Support for Ring Doorbell/Chimes."""
import asyncio
from datetime import timedelta
from functools import partial
import logging
from pathlib import Path

from requests.exceptions import ConnectTimeout, HTTPError
from ring_doorbell import Ring
import voluptuous as vol

from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import track_time_interval
Expand All @@ -21,6 +25,7 @@
DATA_RING_DOORBELLS = "ring_doorbells"
DATA_RING_STICKUP_CAMS = "ring_stickup_cams"
DATA_RING_CHIMES = "ring_chimes"
DATA_TRACK_INTERVAL = "ring_track_interval"

DOMAIN = "ring"
DEFAULT_CACHEDB = ".ring_cache.pickle"
Expand All @@ -29,41 +34,54 @@

SCAN_INTERVAL = timedelta(seconds=10)

PLATFORMS = ("binary_sensor", "light", "sensor", "switch", "camera")

CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
vol.Optional(DOMAIN): vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period,
}
)
},
extra=vol.ALLOW_EXTRA,
)


def setup(hass, config):
async def async_setup(hass, config):
"""Set up the Ring component."""
conf = config[DOMAIN]
username = conf[CONF_USERNAME]
password = conf[CONF_PASSWORD]
scan_interval = conf[CONF_SCAN_INTERVAL]

try:
cache = hass.config.path(DEFAULT_CACHEDB)
ring = Ring(username=username, password=password, cache_file=cache)
if not ring.is_connected:
return False
hass.data[DATA_RING_CHIMES] = chimes = ring.chimes
hass.data[DATA_RING_DOORBELLS] = doorbells = ring.doorbells
hass.data[DATA_RING_STICKUP_CAMS] = stickup_cams = ring.stickup_cams
if DOMAIN not in config:
return True

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"username": config[DOMAIN]["username"],
"password": config[DOMAIN]["password"],
},
)
)
return True

ring_devices = chimes + doorbells + stickup_cams

async def async_setup_entry(hass, entry):
"""Set up a config entry."""
cache = hass.config.path(DEFAULT_CACHEDB)
try:
ring = await hass.async_add_executor_job(
partial(
Ring,
username=entry.data["username"],
password="invalid-password",
cache_file=cache,
)
)
except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Ring service: %s", str(ex))
hass.components.persistent_notification.create(
hass.components.persistent_notification.async_create(
"Error: {}<br />"
"You will need to restart hass after fixing."
"".format(ex),
Expand All @@ -72,6 +90,28 @@ def setup(hass, config):
)
return False

if not ring.is_connected:
_LOGGER.error("Unable to connect to Ring service")
return False

await hass.async_add_executor_job(finish_setup_entry, hass, 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."""
hass.data[DATA_RING_CHIMES] = chimes = ring.chimes
hass.data[DATA_RING_DOORBELLS] = doorbells = ring.doorbells
hass.data[DATA_RING_STICKUP_CAMS] = stickup_cams = ring.stickup_cams

ring_devices = chimes + doorbells + stickup_cams

def service_hub_refresh(service):
hub_refresh()

Expand All @@ -92,6 +132,36 @@ def hub_refresh():
hass.services.register(DOMAIN, "update", service_hub_refresh)

# register scan interval for ring
track_time_interval(hass, timer_hub_refresh, scan_interval)
hass.data[DATA_TRACK_INTERVAL] = track_time_interval(
hass, timer_hub_refresh, SCAN_INTERVAL
)


async def async_unload_entry(hass, entry):
"""Unload Ring entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if not unload_ok:
return False

return True
await hass.async_add_executor_job(hass.data[DATA_TRACK_INTERVAL])

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 unload_ok


async def async_remove_entry(hass, entry):
"""Act when an entry is removed."""
await hass.async_add_executor_job(Path(hass.config.path(DEFAULT_CACHEDB)).unlink)
41 changes: 9 additions & 32 deletions homeassistant/components/ring/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,10 @@
from datetime import timedelta
import logging

import voluptuous as vol

from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice
from homeassistant.const import (
ATTR_ATTRIBUTION,
CONF_ENTITY_NAMESPACE,
CONF_MONITORED_CONDITIONS,
)
import homeassistant.helpers.config_validation as cv

from . import (
ATTRIBUTION,
DATA_RING_DOORBELLS,
DATA_RING_STICKUP_CAMS,
DEFAULT_ENTITY_NAMESPACE,
)
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import ATTR_ATTRIBUTION

from . import ATTRIBUTION, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS

_LOGGER = logging.getLogger(__name__)

Expand All @@ -29,35 +17,24 @@
"motion": ["Motion", ["doorbell", "stickup_cams"], "motion"],
}

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(
CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE
): cv.string,
vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(
cv.ensure_list, [vol.In(SENSOR_TYPES)]
),
}
)


def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up a sensor for a Ring device."""
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]

sensors = []
for device in ring_doorbells: # ring.doorbells is doing I/O
for sensor_type in config[CONF_MONITORED_CONDITIONS]:
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 sensor_type in config[CONF_MONITORED_CONDITIONS]:
for sensor_type in SENSOR_TYPES:
if "stickup_cams" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingBinarySensor(hass, device, sensor_type))

add_entities(sensors, True)
async_add_entities(sensors, True)


class RingBinarySensor(BinarySensorDevice):
Expand Down
66 changes: 20 additions & 46 deletions homeassistant/components/ring/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@

from haffmpeg.camera import CameraMjpeg
from haffmpeg.tools import IMAGE_JPEG, ImageFrame
import voluptuous as vol

from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
from homeassistant.components.camera import Camera
from homeassistant.components.ffmpeg import DATA_FFMPEG
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
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
Expand All @@ -20,77 +18,57 @@
ATTRIBUTION,
DATA_RING_DOORBELLS,
DATA_RING_STICKUP_CAMS,
NOTIFICATION_ID,
SIGNAL_UPDATE_RING,
)

CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments"

FORCE_REFRESH_INTERVAL = timedelta(minutes=45)

_LOGGER = logging.getLogger(__name__)

NOTIFICATION_TITLE = "Ring Camera Setup"

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string}
)


def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a Ring Door Bell and StickUp Camera."""
ring_doorbell = hass.data[DATA_RING_DOORBELLS]
ring_stickup_cams = hass.data[DATA_RING_STICKUP_CAMS]

cams = []
cams_no_plan = []
for camera in ring_doorbell + ring_stickup_cams:
if camera.has_subscription:
cams.append(RingCam(hass, camera, config))
else:
cams_no_plan.append(camera)

# show notification for all cameras without an active subscription
if cams_no_plan:
cameras = str(", ".join([camera.name for camera in cams_no_plan]))

err_msg = (
"""A Ring Protect Plan is required for the"""
""" following cameras: {}.""".format(cameras)
)
if not camera.has_subscription:
continue

_LOGGER.error(err_msg)
hass.components.persistent_notification.create(
"Error: {}<br />"
"You will need to restart hass after fixing."
"".format(err_msg),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
camera = await hass.async_add_executor_job(RingCam, hass, camera)
cams.append(camera)

add_entities(cams, True)
return True
async_add_entities(cams, True)


class RingCam(Camera):
"""An implementation of a Ring Door Bell camera."""

def __init__(self, hass, camera, device_info):
def __init__(self, hass, camera):
"""Initialize a Ring Door Bell camera."""
super().__init__()
self._camera = camera
self._hass = hass
self._name = self._camera.name
self._ffmpeg = hass.data[DATA_FFMPEG]
self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS)
self._last_video_id = self._camera.last_recording_id
self._video_url = self._camera.recording_url(self._last_video_id)
self._utcnow = dt_util.utcnow()
self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
self._disp_disconnect = None

async def async_added_to_hass(self):
"""Register callbacks."""
async_dispatcher_connect(self.hass, SIGNAL_UPDATE_RING, self._update_callback)
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):
Expand Down Expand Up @@ -131,11 +109,7 @@ async def async_camera_image(self):
return

image = await asyncio.shield(
ffmpeg.get_image(
self._video_url,
output_format=IMAGE_JPEG,
extra_cmd=self._ffmpeg_arguments,
)
ffmpeg.get_image(self._video_url, output_format=IMAGE_JPEG,)
)
return image

Expand All @@ -146,7 +120,7 @@ async def handle_async_mjpeg_stream(self, request):
return

stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
await stream.open_camera(self._video_url, extra_cmd=self._ffmpeg_arguments)
await stream.open_camera(self._video_url)

try:
stream_reader = await stream.get_reader()
Expand Down

0 comments on commit 0b3b589

Please sign in to comment.