mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 17:27:52 +00:00
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:
parent
3348f4f6d1
commit
3f29c234b8
28
homeassistant/components/ring/.translations/en.json
Normal file
28
homeassistant/components/ring/.translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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: {}<br />"
|
||||
"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)
|
||||
|
@ -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):
|
||||
|
@ -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: {}<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
|
||||
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()
|
||||
|
105
homeassistant/components/ring/config_flow.py
Normal file
105
homeassistant/components/ring/config_flow.py
Normal 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."""
|
@ -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):
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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):
|
||||
|
27
homeassistant/components/ring/strings.json
Normal file
27
homeassistant/components/ring/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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):
|
||||
|
@ -66,6 +66,7 @@ FLOWS = [
|
||||
"point",
|
||||
"ps4",
|
||||
"rainmachine",
|
||||
"ring",
|
||||
"samsungtv",
|
||||
"sentry",
|
||||
"simplisafe",
|
||||
|
@ -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()
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
|
58
tests/components/ring/test_config_flow.py
Normal file
58
tests/components/ring/test_config_flow.py
Normal 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"}
|
@ -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()
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user