From 3f29c234b8b2cae1c754bf55cdb2f283f8a62701 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Jan 2020 21:35:31 +0100 Subject: [PATCH] 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 --- .../components/ring/.translations/en.json | 28 +++++ homeassistant/components/ring/__init__.py | 112 ++++++++++++++---- .../components/ring/binary_sensor.py | 39 ++---- homeassistant/components/ring/camera.py | 66 ++++------- homeassistant/components/ring/config_flow.py | 105 ++++++++++++++++ homeassistant/components/ring/light.py | 15 ++- homeassistant/components/ring/manifest.json | 3 +- homeassistant/components/ring/sensor.py | 44 +++---- homeassistant/components/ring/strings.json | 27 +++++ homeassistant/components/ring/switch.py | 15 ++- homeassistant/generated/config_flows.py | 1 + tests/components/ring/common.py | 13 +- tests/components/ring/conftest.py | 4 + tests/components/ring/test_binary_sensor.py | 22 +++- tests/components/ring/test_config_flow.py | 58 +++++++++ tests/components/ring/test_init.py | 6 +- tests/components/ring/test_sensor.py | 23 +++- 17 files changed, 435 insertions(+), 146 deletions(-) create mode 100644 homeassistant/components/ring/.translations/en.json create mode 100644 homeassistant/components/ring/config_flow.py create mode 100644 homeassistant/components/ring/strings.json create mode 100644 tests/components/ring/test_config_flow.py diff --git a/homeassistant/components/ring/.translations/en.json b/homeassistant/components/ring/.translations/en.json new file mode 100644 index 00000000000..db4665b6c0a --- /dev/null +++ b/homeassistant/components/ring/.translations/en.json @@ -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" + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index a68749b2c67..18c753f4dc9 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -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 @@ -21,6 +25,7 @@ NOTIFICATION_TITLE = "Ring Setup" 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" @@ -29,13 +34,14 @@ SIGNAL_UPDATE_RING = "ring_update" 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, } ) }, @@ -43,27 +49,39 @@ CONFIG_SCHEMA = vol.Schema( ) -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] + 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 + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + cache = hass.config.path(DEFAULT_CACHEDB) 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 - - ring_devices = chimes + doorbells + stickup_cams - + 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: {}
" "You will need to restart hass after fixing." "".format(ex), @@ -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() @@ -92,6 +132,36 @@ def setup(hass, config): 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 + ) - 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) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 86d26ec25b4..0706752ffb2 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -2,22 +2,10 @@ from datetime import timedelta 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 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 . import ATTRIBUTION, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS _LOGGER = logging.getLogger(__name__) @@ -29,35 +17,24 @@ SENSOR_TYPES = { "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): diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 1d2fe6ff67b..a3b34afa056 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -5,13 +5,11 @@ import logging 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 @@ -20,77 +18,57 @@ from . import ( 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) + if not camera.has_subscription: + continue - # show notification for all cameras without an active subscription - if cams_no_plan: - cameras = str(", ".join([camera.name for camera in cams_no_plan])) + camera = await hass.async_add_executor_job(RingCam, hass, camera) + cams.append(camera) - err_msg = ( - """A Ring Protect Plan is required for the""" - """ following cameras: {}.""".format(cameras) - ) - - _LOGGER.error(err_msg) - hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(err_msg), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - - 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): @@ -131,11 +109,7 @@ class RingCam(Camera): 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 @@ -146,7 +120,7 @@ class RingCam(Camera): 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() diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py new file mode 100644 index 00000000000..bdb60cc26c5 --- /dev/null +++ b/homeassistant/components/ring/config_flow.py @@ -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.""" diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index fe048731352..1b360f24f1f 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -23,7 +23,7 @@ ON_STATE = "on" 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.""" cameras = hass.data[DATA_RING_STICKUP_CAMS] lights = [] @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if device.has_capability("light"): lights.append(RingLight(device)) - add_entities(lights, True) + async_add_entities(lights, True) class RingLight(Light): @@ -44,10 +44,19 @@ class RingLight(Light): self._unique_id = self._device.id self._light_on = False self._no_updates_until = dt_util.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): diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 124df7d162b..b8a3c26bd8b 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "requirements": ["ring_doorbell==0.2.9"], "dependencies": ["ffmpeg"], - "codeowners": [] + "codeowners": [], + "config_flow": true } diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index b54c750664e..532f15f94c1 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,16 +1,8 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" import logging -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_ENTITY_NAMESPACE, - CONF_MONITORED_CONDITIONS, -) +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -20,7 +12,6 @@ from . import ( DATA_RING_CHIMES, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS, - DEFAULT_ENTITY_NAMESPACE, 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)] - ), - } -) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a sensor for a Ring device.""" ring_chimes = hass.data[DATA_RING_CHIMES] ring_doorbells = hass.data[DATA_RING_DOORBELLS] @@ -87,22 +67,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [] 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]: sensors.append(RingSensor(hass, device, sensor_type)) 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]: sensors.append(RingSensor(hass, device, sensor_type)) 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]: sensors.append(RingSensor(hass, device, sensor_type)) - add_entities(sensors, True) - return True + async_add_entities(sensors, True) class RingSensor(Entity): @@ -122,10 +101,19 @@ class RingSensor(Entity): self._state = None self._tz = str(hass.config.time_zone) self._unique_id = f"{self._data.id}-{self._sensor_type}" + 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): diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json new file mode 100644 index 00000000000..6dff7c00ba6 --- /dev/null +++ b/homeassistant/components/ring/strings.json @@ -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" + } + } +} diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 86f5c65d87c..51c9e64377b 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -22,7 +22,7 @@ SIREN_ICON = "mdi:alarm-bell" 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.""" cameras = hass.data[DATA_RING_STICKUP_CAMS] switches = [] @@ -30,7 +30,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if device.has_capability("siren"): switches.append(SirenSwitch(device)) - add_entities(switches, True) + async_add_entities(switches, True) class BaseRingSwitch(SwitchDevice): @@ -41,10 +41,19 @@ class BaseRingSwitch(SwitchDevice): self._device = 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.""" - 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): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c5ea3f1a5d9..76e10becfb2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -66,6 +66,7 @@ FLOWS = [ "point", "ps4", "rainmachine", + "ring", "samsungtv", "sentry", "simplisafe", diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index e5042a935d6..1afc597415e 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -1,14 +1,15 @@ """Common methods used across the tests for ring devices.""" +from unittest.mock import patch + 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 tests.common import MockConfigEntry + async def setup_platform(hass, platform): """Set up the ring platform and prerequisites.""" - config = { - DOMAIN: {CONF_USERNAME: "foo", CONF_PASSWORD: "bar", CONF_SCAN_INTERVAL: 1000}, - platform: {"platform": DOMAIN}, - } - assert await async_setup_component(hass, platform, config) + MockConfigEntry(domain=DOMAIN, data={"username": "foo"}).add_to_hass(hass) + with patch("homeassistant.components.ring.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index b61840769a2..e4b516496e7 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -36,6 +36,10 @@ def requests_mock_fixture(ring_mock): "https://api.ring.com/clients_api/ring_devices", 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 mock.get( "https://api.ring.com/clients_api/doorbots/987652/history", diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index c0b538b8eff..5a04017f54b 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,13 +1,20 @@ """The tests for the Ring binary sensor platform.""" +from asyncio import run_coroutine_threadsafe import os import unittest +from unittest.mock import patch import requests_mock from homeassistant.components import ring as base_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 @@ -68,8 +75,17 @@ class TestRingBinarySensorSetup(unittest.TestCase): 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: device.update() diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py new file mode 100644 index 00000000000..46925069c31 --- /dev/null +++ b/tests/components/ring/test_config_flow.py @@ -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"} diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 4d3fede89a9..cfc19da78bf 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -1,4 +1,5 @@ """The tests for the Ring component.""" +from asyncio import run_coroutine_threadsafe from copy import deepcopy from datetime import timedelta import os @@ -59,7 +60,10 @@ class TestRing(unittest.TestCase): "https://api.ring.com/clients_api/doorbots/987652/health", 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 @requests_mock.Mocker() diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index dd9d36f80a1..0102020e3c2 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -1,6 +1,8 @@ """The tests for the Ring sensor platform.""" +from asyncio import run_coroutine_threadsafe import os import unittest +from unittest.mock import patch import requests_mock @@ -8,7 +10,12 @@ from homeassistant.components import ring as base_ring import homeassistant.components.ring.sensor as ring 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 @@ -76,8 +83,18 @@ class TestRingSensorSetup(unittest.TestCase): "https://api.ring.com/clients_api/chimes/999999/health", 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: device.update()