Add Ring config flow (#30564)

* Add Ring config flow

* Address comments + migrate platforms to config entry

* Migrate camera too

* Address comments

* Fix order config flows

* setup -> async_setup
This commit is contained in:
Paulus Schoutsen 2020-01-10 21:35:31 +01:00 committed by GitHub
parent 3348f4f6d1
commit 3f29c234b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 435 additions and 146 deletions

View File

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

View File

@ -1,12 +1,16 @@
"""Support for Ring Doorbell/Chimes.""" """Support for Ring Doorbell/Chimes."""
import asyncio
from datetime import timedelta from datetime import timedelta
from functools import partial
import logging import logging
from pathlib import Path
from requests.exceptions import ConnectTimeout, HTTPError from requests.exceptions import ConnectTimeout, HTTPError
from ring_doorbell import Ring from ring_doorbell import Ring
import voluptuous as vol 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.event import track_time_interval
@ -21,6 +25,7 @@ NOTIFICATION_TITLE = "Ring Setup"
DATA_RING_DOORBELLS = "ring_doorbells" DATA_RING_DOORBELLS = "ring_doorbells"
DATA_RING_STICKUP_CAMS = "ring_stickup_cams" DATA_RING_STICKUP_CAMS = "ring_stickup_cams"
DATA_RING_CHIMES = "ring_chimes" DATA_RING_CHIMES = "ring_chimes"
DATA_TRACK_INTERVAL = "ring_track_interval"
DOMAIN = "ring" DOMAIN = "ring"
DEFAULT_CACHEDB = ".ring_cache.pickle" DEFAULT_CACHEDB = ".ring_cache.pickle"
@ -29,13 +34,14 @@ SIGNAL_UPDATE_RING = "ring_update"
SCAN_INTERVAL = timedelta(seconds=10) SCAN_INTERVAL = timedelta(seconds=10)
PLATFORMS = ("binary_sensor", "light", "sensor", "switch", "camera")
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( vol.Optional(DOMAIN): vol.Schema(
{ {
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period,
} }
) )
}, },
@ -43,27 +49,39 @@ CONFIG_SCHEMA = vol.Schema(
) )
def setup(hass, config): async def async_setup(hass, config):
"""Set up the Ring component.""" """Set up the Ring component."""
conf = config[DOMAIN] if DOMAIN not in config:
username = conf[CONF_USERNAME] return True
password = conf[CONF_PASSWORD]
scan_interval = conf[CONF_SCAN_INTERVAL]
try: 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
async def async_setup_entry(hass, entry):
"""Set up a config entry."""
cache = hass.config.path(DEFAULT_CACHEDB) cache = hass.config.path(DEFAULT_CACHEDB)
ring = Ring(username=username, password=password, cache_file=cache) try:
if not ring.is_connected: ring = await hass.async_add_executor_job(
return False partial(
hass.data[DATA_RING_CHIMES] = chimes = ring.chimes Ring,
hass.data[DATA_RING_DOORBELLS] = doorbells = ring.doorbells username=entry.data["username"],
hass.data[DATA_RING_STICKUP_CAMS] = stickup_cams = ring.stickup_cams password="invalid-password",
cache_file=cache,
ring_devices = chimes + doorbells + stickup_cams )
)
except (ConnectTimeout, HTTPError) as ex: except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Ring service: %s", str(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 />" "Error: {}<br />"
"You will need to restart hass after fixing." "You will need to restart hass after fixing."
"".format(ex), "".format(ex),
@ -72,6 +90,28 @@ def setup(hass, config):
) )
return False 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): def service_hub_refresh(service):
hub_refresh() hub_refresh()
@ -92,6 +132,36 @@ def setup(hass, config):
hass.services.register(DOMAIN, "update", service_hub_refresh) hass.services.register(DOMAIN, "update", service_hub_refresh)
# register scan interval for ring # 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
)
return True
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
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)

View File

@ -2,22 +2,10 @@
from datetime import timedelta from datetime import timedelta
import logging import logging
import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice from . import ATTRIBUTION, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS
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,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,35 +17,24 @@ SENSOR_TYPES = {
"motion": ["Motion", ["doorbell", "stickup_cams"], "motion"], "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)]
),
}
)
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Ring binary sensors from a config entry."""
"""Set up a sensor for a Ring device."""
ring_doorbells = hass.data[DATA_RING_DOORBELLS] ring_doorbells = hass.data[DATA_RING_DOORBELLS]
ring_stickup_cams = hass.data[DATA_RING_STICKUP_CAMS] ring_stickup_cams = hass.data[DATA_RING_STICKUP_CAMS]
sensors = [] sensors = []
for device in ring_doorbells: # ring.doorbells is doing I/O 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]: if "doorbell" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingBinarySensor(hass, device, sensor_type)) sensors.append(RingBinarySensor(hass, device, sensor_type))
for device in ring_stickup_cams: # ring.stickup_cams is doing I/O 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]: if "stickup_cams" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingBinarySensor(hass, device, sensor_type)) sensors.append(RingBinarySensor(hass, device, sensor_type))
add_entities(sensors, True) async_add_entities(sensors, True)
class RingBinarySensor(BinarySensorDevice): class RingBinarySensor(BinarySensorDevice):

View File

@ -5,13 +5,11 @@ import logging
from haffmpeg.camera import CameraMjpeg from haffmpeg.camera import CameraMjpeg
from haffmpeg.tools import IMAGE_JPEG, ImageFrame 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.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 import config_validation as cv
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.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -20,77 +18,57 @@ from . import (
ATTRIBUTION, ATTRIBUTION,
DATA_RING_DOORBELLS, DATA_RING_DOORBELLS,
DATA_RING_STICKUP_CAMS, DATA_RING_STICKUP_CAMS,
NOTIFICATION_ID,
SIGNAL_UPDATE_RING, SIGNAL_UPDATE_RING,
) )
CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments"
FORCE_REFRESH_INTERVAL = timedelta(minutes=45) FORCE_REFRESH_INTERVAL = timedelta(minutes=45)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
NOTIFICATION_TITLE = "Ring Camera Setup"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_entry(hass, config_entry, async_add_entities):
{vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string}
)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up a Ring Door Bell and StickUp Camera.""" """Set up a Ring Door Bell and StickUp Camera."""
ring_doorbell = hass.data[DATA_RING_DOORBELLS] ring_doorbell = hass.data[DATA_RING_DOORBELLS]
ring_stickup_cams = hass.data[DATA_RING_STICKUP_CAMS] ring_stickup_cams = hass.data[DATA_RING_STICKUP_CAMS]
cams = [] cams = []
cams_no_plan = []
for camera in ring_doorbell + ring_stickup_cams: for camera in ring_doorbell + ring_stickup_cams:
if camera.has_subscription: if not camera.has_subscription:
cams.append(RingCam(hass, camera, config)) continue
else:
cams_no_plan.append(camera)
# show notification for all cameras without an active subscription camera = await hass.async_add_executor_job(RingCam, hass, camera)
if cams_no_plan: cams.append(camera)
cameras = str(", ".join([camera.name for camera in cams_no_plan]))
err_msg = ( async_add_entities(cams, True)
"""A Ring Protect Plan is required for the"""
""" following cameras: {}.""".format(cameras)
)
_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,
)
add_entities(cams, True)
return True
class RingCam(Camera): class RingCam(Camera):
"""An implementation of a Ring Door Bell 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.""" """Initialize a Ring Door Bell camera."""
super().__init__() super().__init__()
self._camera = camera self._camera = camera
self._hass = hass self._hass = hass
self._name = self._camera.name self._name = self._camera.name
self._ffmpeg = hass.data[DATA_FFMPEG] 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._last_video_id = self._camera.last_recording_id
self._video_url = self._camera.recording_url(self._last_video_id) self._video_url = self._camera.recording_url(self._last_video_id)
self._utcnow = dt_util.utcnow() self._utcnow = dt_util.utcnow()
self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
self._disp_disconnect = None
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """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 @callback
def _update_callback(self): def _update_callback(self):
@ -131,11 +109,7 @@ class RingCam(Camera):
return return
image = await asyncio.shield( image = await asyncio.shield(
ffmpeg.get_image( ffmpeg.get_image(self._video_url, output_format=IMAGE_JPEG,)
self._video_url,
output_format=IMAGE_JPEG,
extra_cmd=self._ffmpeg_arguments,
)
) )
return image return image
@ -146,7 +120,7 @@ class RingCam(Camera):
return return
stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) 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: try:
stream_reader = await stream.get_reader() stream_reader = await stream.get_reader()

View File

@ -0,0 +1,105 @@
"""Config flow for Ring integration."""
from functools import partial
import logging
from oauthlib.oauth2 import AccessDeniedError
from ring_doorbell import Ring
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from . import DEFAULT_CACHEDB, DOMAIN # pylint: disable=unused-import
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect."""
cache = hass.config.path(DEFAULT_CACHEDB)
def otp_callback():
if "2fa" in data:
return data["2fa"]
raise Require2FA
try:
ring = await hass.async_add_executor_job(
partial(
Ring,
username=data["username"],
password=data["password"],
cache_file=cache,
auth_callback=otp_callback,
)
)
except AccessDeniedError:
raise InvalidAuth
if not ring.is_connected:
raise InvalidAuth
class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ring."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
user_pass = None
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:
await validate_input(self.hass, user_input)
await self.async_set_unique_id(user_input["username"])
return self.async_create_entry(
title=user_input["username"],
data={"username": user_input["username"]},
)
except Require2FA:
self.user_pass = user_input
return await self.async_step_2fa()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({"username": str, "password": str}),
errors=errors,
)
async def async_step_2fa(self, user_input=None):
"""Handle 2fa step."""
if user_input:
return await self.async_step_user({**self.user_pass, **user_input})
return self.async_show_form(
step_id="2fa", data_schema=vol.Schema({"2fa": str}),
)
async def async_step_import(self, user_input):
"""Handle import."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")
return await self.async_step_user(user_input)
class Require2FA(exceptions.HomeAssistantError):
"""Error to indicate we require 2FA."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -23,7 +23,7 @@ ON_STATE = "on"
OFF_STATE = "off" OFF_STATE = "off"
def setup_platform(hass, config, add_entities, discovery_info=None): 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."""
cameras = hass.data[DATA_RING_STICKUP_CAMS] cameras = hass.data[DATA_RING_STICKUP_CAMS]
lights = [] lights = []
@ -32,7 +32,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
if device.has_capability("light"): if device.has_capability("light"):
lights.append(RingLight(device)) lights.append(RingLight(device))
add_entities(lights, True) async_add_entities(lights, True)
class RingLight(Light): class RingLight(Light):
@ -44,10 +44,19 @@ class RingLight(Light):
self._unique_id = self._device.id self._unique_id = self._device.id
self._light_on = False self._light_on = False
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): async def async_added_to_hass(self):
"""Register callbacks.""" """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 @callback
def _update_callback(self): def _update_callback(self):

View File

@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/ring", "documentation": "https://www.home-assistant.io/integrations/ring",
"requirements": ["ring_doorbell==0.2.9"], "requirements": ["ring_doorbell==0.2.9"],
"dependencies": ["ffmpeg"], "dependencies": ["ffmpeg"],
"codeowners": [] "codeowners": [],
"config_flow": true
} }

View File

@ -1,16 +1,8 @@
"""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
import voluptuous as vol from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
ATTR_ATTRIBUTION,
CONF_ENTITY_NAMESPACE,
CONF_MONITORED_CONDITIONS,
)
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect 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
@ -20,7 +12,6 @@ from . import (
DATA_RING_CHIMES, DATA_RING_CHIMES,
DATA_RING_DOORBELLS, DATA_RING_DOORBELLS,
DATA_RING_STICKUP_CAMS, DATA_RING_STICKUP_CAMS,
DEFAULT_ENTITY_NAMESPACE,
SIGNAL_UPDATE_RING, SIGNAL_UPDATE_RING,
) )
@ -67,19 +58,8 @@ SENSOR_TYPES = {
], ],
} }
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)]
),
}
)
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up a sensor for a Ring device.""" """Set up a sensor for a Ring device."""
ring_chimes = hass.data[DATA_RING_CHIMES] ring_chimes = hass.data[DATA_RING_CHIMES]
ring_doorbells = hass.data[DATA_RING_DOORBELLS] ring_doorbells = hass.data[DATA_RING_DOORBELLS]
@ -87,22 +67,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
sensors = [] sensors = []
for device in ring_chimes: for device in ring_chimes:
for sensor_type in config[CONF_MONITORED_CONDITIONS]: for sensor_type in SENSOR_TYPES:
if "chime" in SENSOR_TYPES[sensor_type][1]: if "chime" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingSensor(hass, device, sensor_type)) sensors.append(RingSensor(hass, device, sensor_type))
for device in ring_doorbells: for device in ring_doorbells:
for sensor_type in config[CONF_MONITORED_CONDITIONS]: for sensor_type in SENSOR_TYPES:
if "doorbell" in SENSOR_TYPES[sensor_type][1]: if "doorbell" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingSensor(hass, device, sensor_type)) sensors.append(RingSensor(hass, device, sensor_type))
for device in ring_stickup_cams: for device in ring_stickup_cams:
for sensor_type in config[CONF_MONITORED_CONDITIONS]: for sensor_type in SENSOR_TYPES:
if "stickup_cams" in SENSOR_TYPES[sensor_type][1]: if "stickup_cams" in SENSOR_TYPES[sensor_type][1]:
sensors.append(RingSensor(hass, device, sensor_type)) sensors.append(RingSensor(hass, device, sensor_type))
add_entities(sensors, True) async_add_entities(sensors, True)
return True
class RingSensor(Entity): class RingSensor(Entity):
@ -122,10 +101,19 @@ class RingSensor(Entity):
self._state = None self._state = None
self._tz = str(hass.config.time_zone) self._tz = str(hass.config.time_zone)
self._unique_id = f"{self._data.id}-{self._sensor_type}" self._unique_id = f"{self._data.id}-{self._sensor_type}"
self._disp_disconnect = None
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """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 @callback
def _update_callback(self): def _update_callback(self):

View File

@ -0,0 +1,27 @@
{
"config": {
"title": "Ring",
"step": {
"user": {
"title": "Sign-in with Ring account",
"data": {
"username": "Username",
"password": "Password"
}
},
"2fa": {
"title": "Two-factor authentication",
"data": {
"2fa": "Two-factor code"
}
}
},
"error": {
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
}
}

View File

@ -22,7 +22,7 @@ SIREN_ICON = "mdi:alarm-bell"
SKIP_UPDATES_DELAY = timedelta(seconds=5) SKIP_UPDATES_DELAY = timedelta(seconds=5)
def setup_platform(hass, config, add_entities, discovery_info=None): 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."""
cameras = hass.data[DATA_RING_STICKUP_CAMS] cameras = hass.data[DATA_RING_STICKUP_CAMS]
switches = [] switches = []
@ -30,7 +30,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
if device.has_capability("siren"): if device.has_capability("siren"):
switches.append(SirenSwitch(device)) switches.append(SirenSwitch(device))
add_entities(switches, True) async_add_entities(switches, True)
class BaseRingSwitch(SwitchDevice): class BaseRingSwitch(SwitchDevice):
@ -41,10 +41,19 @@ class BaseRingSwitch(SwitchDevice):
self._device = device self._device = 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): async def async_added_to_hass(self):
"""Register callbacks.""" """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 @callback
def _update_callback(self): def _update_callback(self):

View File

@ -66,6 +66,7 @@ FLOWS = [
"point", "point",
"ps4", "ps4",
"rainmachine", "rainmachine",
"ring",
"samsungtv", "samsungtv",
"sentry", "sentry",
"simplisafe", "simplisafe",

View File

@ -1,14 +1,15 @@
"""Common methods used across the tests for ring devices.""" """Common methods used across the tests for ring devices."""
from unittest.mock import patch
from homeassistant.components.ring import DOMAIN from homeassistant.components.ring import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def setup_platform(hass, platform): async def setup_platform(hass, platform):
"""Set up the ring platform and prerequisites.""" """Set up the ring platform and prerequisites."""
config = { MockConfigEntry(domain=DOMAIN, data={"username": "foo"}).add_to_hass(hass)
DOMAIN: {CONF_USERNAME: "foo", CONF_PASSWORD: "bar", CONF_SCAN_INTERVAL: 1000}, with patch("homeassistant.components.ring.PLATFORMS", [platform]):
platform: {"platform": DOMAIN}, assert await async_setup_component(hass, DOMAIN, {})
}
assert await async_setup_component(hass, platform, config)
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -36,6 +36,10 @@ def requests_mock_fixture(ring_mock):
"https://api.ring.com/clients_api/ring_devices", "https://api.ring.com/clients_api/ring_devices",
text=load_fixture("ring_devices.json"), text=load_fixture("ring_devices.json"),
) )
mock.get(
"https://api.ring.com/clients_api/dings/active",
text=load_fixture("ring_ding_active.json"),
)
# Mocks the response for getting the history of a device # Mocks the response for getting the history of a device
mock.get( mock.get(
"https://api.ring.com/clients_api/doorbots/987652/history", "https://api.ring.com/clients_api/doorbots/987652/history",

View File

@ -1,13 +1,20 @@
"""The tests for the Ring binary sensor platform.""" """The tests for the Ring binary sensor platform."""
from asyncio import run_coroutine_threadsafe
import os import os
import unittest import unittest
from unittest.mock import patch
import requests_mock import requests_mock
from homeassistant.components import ring as base_ring from homeassistant.components import ring as base_ring
from homeassistant.components.ring import binary_sensor as ring from homeassistant.components.ring import binary_sensor as ring
from tests.common import get_test_config_dir, get_test_home_assistant, load_fixture from tests.common import (
get_test_config_dir,
get_test_home_assistant,
load_fixture,
mock_storage,
)
from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG
@ -68,8 +75,17 @@ class TestRingBinarySensorSetup(unittest.TestCase):
text=load_fixture("ring_chime_health_attrs.json"), text=load_fixture("ring_chime_health_attrs.json"),
) )
base_ring.setup(self.hass, VALID_CONFIG) with mock_storage(), patch("homeassistant.components.ring.PLATFORMS", []):
ring.setup_platform(self.hass, self.config, self.add_entities, None) run_coroutine_threadsafe(
base_ring.async_setup(self.hass, VALID_CONFIG), self.hass.loop
).result()
run_coroutine_threadsafe(
self.hass.async_block_till_done(), self.hass.loop
).result()
run_coroutine_threadsafe(
ring.async_setup_entry(self.hass, None, self.add_entities),
self.hass.loop,
).result()
for device in self.DEVICES: for device in self.DEVICES:
device.update() device.update()

View File

@ -0,0 +1,58 @@
"""Test the Ring config flow."""
from unittest.mock import Mock, patch
from homeassistant import config_entries, setup
from homeassistant.components.ring import DOMAIN
from homeassistant.components.ring.config_flow import InvalidAuth
from tests.common import mock_coro
async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.ring.config_flow.Ring",
return_value=Mock(is_connected=True),
), patch(
"homeassistant.components.ring.async_setup", return_value=mock_coro(True)
) as mock_setup, patch(
"homeassistant.components.ring.async_setup_entry", return_value=mock_coro(True),
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "hello@home-assistant.io", "password": "test-password"},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "hello@home-assistant.io"
assert result2["data"] == {
"username": "hello@home-assistant.io",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.ring.config_flow.Ring", side_effect=InvalidAuth,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "hello@home-assistant.io", "password": "test-password"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}

View File

@ -1,4 +1,5 @@
"""The tests for the Ring component.""" """The tests for the Ring component."""
from asyncio import run_coroutine_threadsafe
from copy import deepcopy from copy import deepcopy
from datetime import timedelta from datetime import timedelta
import os import os
@ -59,7 +60,10 @@ class TestRing(unittest.TestCase):
"https://api.ring.com/clients_api/doorbots/987652/health", "https://api.ring.com/clients_api/doorbots/987652/health",
text=load_fixture("ring_doorboot_health_attrs.json"), text=load_fixture("ring_doorboot_health_attrs.json"),
) )
response = ring.setup(self.hass, self.config) response = run_coroutine_threadsafe(
ring.async_setup(self.hass, self.config), self.hass.loop
).result()
assert response assert response
@requests_mock.Mocker() @requests_mock.Mocker()

View File

@ -1,6 +1,8 @@
"""The tests for the Ring sensor platform.""" """The tests for the Ring sensor platform."""
from asyncio import run_coroutine_threadsafe
import os import os
import unittest import unittest
from unittest.mock import patch
import requests_mock import requests_mock
@ -8,7 +10,12 @@ from homeassistant.components import ring as base_ring
import homeassistant.components.ring.sensor as ring import homeassistant.components.ring.sensor as ring
from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.icon import icon_for_battery_level
from tests.common import get_test_config_dir, get_test_home_assistant, load_fixture from tests.common import (
get_test_config_dir,
get_test_home_assistant,
load_fixture,
mock_storage,
)
from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG
@ -76,8 +83,18 @@ class TestRingSensorSetup(unittest.TestCase):
"https://api.ring.com/clients_api/chimes/999999/health", "https://api.ring.com/clients_api/chimes/999999/health",
text=load_fixture("ring_chime_health_attrs.json"), text=load_fixture("ring_chime_health_attrs.json"),
) )
base_ring.setup(self.hass, VALID_CONFIG)
ring.setup_platform(self.hass, self.config, self.add_entities, None) with mock_storage(), patch("homeassistant.components.ring.PLATFORMS", []):
run_coroutine_threadsafe(
base_ring.async_setup(self.hass, VALID_CONFIG), self.hass.loop
).result()
run_coroutine_threadsafe(
self.hass.async_block_till_done(), self.hass.loop
).result()
run_coroutine_threadsafe(
ring.async_setup_entry(self.hass, None, self.add_entities),
self.hass.loop,
).result()
for device in self.DEVICES: for device in self.DEVICES:
device.update() device.update()