diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 18c753f4dc9..7addc116b06 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -5,8 +5,7 @@ from functools import partial import logging from pathlib import Path -from requests.exceptions import ConnectTimeout, HTTPError -from ring_doorbell import Ring +from ring_doorbell import Auth, Ring import voluptuous as vol from homeassistant import config_entries @@ -14,6 +13,7 @@ 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 +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,6 @@ DATA_RING_CHIMES = "ring_chimes" DATA_TRACK_INTERVAL = "ring_track_interval" DOMAIN = "ring" -DEFAULT_CACHEDB = ".ring_cache.pickle" DEFAULT_ENTITY_NAMESPACE = "ring" SIGNAL_UPDATE_RING = "ring_update" @@ -54,6 +53,14 @@ async def async_setup(hass, config): if DOMAIN not in config: return True + def legacy_cleanup(): + """Clean up old tokens.""" + old_cache = Path(hass.config.path(".ring_cache.pickle")) + if old_cache.is_file(): + old_cache.unlink() + + await hass.async_add_executor_job(legacy_cleanup) + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -69,30 +76,20 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up a config entry.""" - cache = hass.config.path(DEFAULT_CACHEDB) - try: - ring = await hass.async_add_executor_job( - partial( - Ring, - username=entry.data["username"], - password="invalid-password", - cache_file=cache, - ) - ) - except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Ring service: %s", str(ex)) - hass.components.persistent_notification.async_create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - return False - if not ring.is_connected: - _LOGGER.error("Unable to connect to Ring service") - return False + def token_updater(token): + """Handle from sync context when token is updated.""" + run_callback_threadsafe( + hass.loop, + partial( + hass.config_entries.async_update_entry, + entry, + data={**entry.data, "token": token}, + ), + ).result() + + auth = Auth(entry.data["token"], token_updater) + ring = Ring(auth) await hass.async_add_executor_job(finish_setup_entry, hass, ring) @@ -106,9 +103,10 @@ async def async_setup_entry(hass, entry): 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 + devices = ring.devices + hass.data[DATA_RING_CHIMES] = chimes = devices["chimes"] + hass.data[DATA_RING_DOORBELLS] = doorbells = devices["doorbells"] + hass.data[DATA_RING_STICKUP_CAMS] = stickup_cams = devices["stickup_cams"] ring_devices = chimes + doorbells + stickup_cams @@ -160,8 +158,3 @@ async def async_unload_entry(hass, entry): 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 0706752ffb2..29337f29689 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.const import ATTR_ATTRIBUTION -from . import ATTRIBUTION, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS +from . import ATTRIBUTION, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -72,14 +72,23 @@ class RingBinarySensor(BinarySensorDevice): """Return a unique ID.""" return self._unique_id + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._data.id)}, + "sw_version": self._data.firmware, + "name": self._data.name, + "model": self._data.kind, + "manufacturer": "Ring", + } + @property def device_state_attributes(self): """Return the state attributes.""" attrs = {} attrs[ATTR_ATTRIBUTION] = ATTRIBUTION - attrs["device_id"] = self._data.id - attrs["firmware"] = self._data.firmware attrs["timezone"] = self._data.timezone if self._data.alert and self._data.alert_expires_at: diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index a3b34afa056..2b0fe14a1d4 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -18,6 +18,7 @@ from . import ( ATTRIBUTION, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS, + DOMAIN, SIGNAL_UPDATE_RING, ) @@ -86,16 +87,23 @@ class RingCam(Camera): """Return a unique ID.""" return self._camera.id + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._camera.id)}, + "sw_version": self._camera.firmware, + "name": self._camera.name, + "model": self._camera.kind, + "manufacturer": "Ring", + } + @property def device_state_attributes(self): """Return the state attributes.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, - "device_id": self._camera.id, - "firmware": self._camera.firmware, - "kind": self._camera.kind, "timezone": self._camera.timezone, - "type": self._camera.family, "video_url": self._video_url, "last_video_id": self._last_video_id, } diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index bdb60cc26c5..98555277baf 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -1,21 +1,19 @@ """Config flow for Ring integration.""" -from functools import partial import logging from oauthlib.oauth2 import AccessDeniedError -from ring_doorbell import Ring +from ring_doorbell import Auth import voluptuous as vol from homeassistant import config_entries, core, exceptions -from . import DEFAULT_CACHEDB, DOMAIN # pylint: disable=unused-import +from . import 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: @@ -23,21 +21,16 @@ async def validate_input(hass: core.HomeAssistant, data): raise Require2FA + auth = Auth() + try: - ring = await hass.async_add_executor_job( - partial( - Ring, - username=data["username"], - password=data["password"], - cache_file=cache, - auth_callback=otp_callback, - ) + token = await hass.async_add_executor_job( + auth.fetch_token, data["username"], data["password"], otp_callback, ) except AccessDeniedError: raise InvalidAuth - if not ring.is_connected: - raise InvalidAuth + return token class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -56,12 +49,12 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - await validate_input(self.hass, user_input) + token = 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"]}, + data={"username": user_input["username"], "token": token}, ) except Require2FA: self.user_pass = user_input diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 1b360f24f1f..b7fa67a391f 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -7,7 +7,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.dt as dt_util -from . import DATA_RING_STICKUP_CAMS, SIGNAL_UPDATE_RING +from . import DATA_RING_STICKUP_CAMS, DOMAIN, SIGNAL_UPDATE_RING _LOGGER = logging.getLogger(__name__) @@ -84,6 +84,17 @@ class RingLight(Light): """If the switch is currently on or off.""" return self._light_on + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._device.id)}, + "sw_version": self._device.firmware, + "name": self._device.name, + "model": self._device.kind, + "manufacturer": "Ring", + } + def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" self._device.lights = new_state diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index b8a3c26bd8b..d6570fad5cb 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -2,7 +2,7 @@ "domain": "ring", "name": "Ring", "documentation": "https://www.home-assistant.io/integrations/ring", - "requirements": ["ring_doorbell==0.2.9"], + "requirements": ["ring_doorbell==0.4.0"], "dependencies": ["ffmpeg"], "codeowners": [], "config_flow": true diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 532f15f94c1..89b042ba862 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -12,6 +12,7 @@ from . import ( DATA_RING_CHIMES, DATA_RING_DOORBELLS, DATA_RING_STICKUP_CAMS, + DOMAIN, SIGNAL_UPDATE_RING, ) @@ -108,6 +109,7 @@ class RingSensor(Entity): self._disp_disconnect = async_dispatcher_connect( self.hass, SIGNAL_UPDATE_RING, self._update_callback ) + await self.hass.async_add_executor_job(self._data.update) async def async_will_remove_from_hass(self): """Disconnect callbacks.""" @@ -140,17 +142,24 @@ class RingSensor(Entity): """Return a unique ID.""" return self._unique_id + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._data.id)}, + "sw_version": self._data.firmware, + "name": self._data.name, + "model": self._data.kind, + "manufacturer": "Ring", + } + @property def device_state_attributes(self): """Return the state attributes.""" attrs = {} attrs[ATTR_ATTRIBUTION] = ATTRIBUTION - attrs["device_id"] = self._data.id - attrs["firmware"] = self._data.firmware - attrs["kind"] = self._data.kind attrs["timezone"] = self._data.timezone - attrs["type"] = self._data.family attrs["wifi_name"] = self._data.wifi_name if self._extra and self._sensor_type.startswith("last_"): diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 51c9e64377b..e23e757d825 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -7,7 +7,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.dt as dt_util -from . import DATA_RING_STICKUP_CAMS, SIGNAL_UPDATE_RING +from . import DATA_RING_STICKUP_CAMS, DOMAIN, SIGNAL_UPDATE_RING _LOGGER = logging.getLogger(__name__) @@ -76,6 +76,17 @@ class BaseRingSwitch(SwitchDevice): """Update controlled via the hub.""" return False + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": {(DOMAIN, self._device.id)}, + "sw_version": self._device.firmware, + "name": self._device.name, + "model": self._device.kind, + "manufacturer": "Ring", + } + class SirenSwitch(BaseRingSwitch): """Creates a switch to turn the ring cameras siren on and off.""" diff --git a/requirements_all.txt b/requirements_all.txt index d8916c184f8..0c1159256c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1753,7 +1753,7 @@ rfk101py==0.0.1 rflink==0.0.50 # homeassistant.components.ring -ring_doorbell==0.2.9 +ring_doorbell==0.4.0 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cf4a9f8e89..ea60a13b565 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -570,7 +570,7 @@ restrictedpython==5.0 rflink==0.0.50 # homeassistant.components.ring -ring_doorbell==0.2.9 +ring_doorbell==0.4.0 # homeassistant.components.yamaha rxv==0.6.0 diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index 1afc597415e..93a6e4f91e0 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -9,7 +9,9 @@ from tests.common import MockConfigEntry async def setup_platform(hass, platform): """Set up the ring platform and prerequisites.""" - MockConfigEntry(domain=DOMAIN, data={"username": "foo"}).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data={"username": "foo", "token": {}}).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 e4b516496e7..a4cfaf0065d 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -1,21 +1,12 @@ """Configuration for Ring tests.""" -from asynctest import patch import pytest import requests_mock from tests.common import load_fixture -@pytest.fixture(name="ring_mock") -def ring_save_mock(): - """Fixture to mock a ring.""" - with patch("ring_doorbell._exists_cache", return_value=False): - with patch("ring_doorbell._save_cache", return_value=True) as save_mock: - yield save_mock - - @pytest.fixture(name="requests_mock") -def requests_mock_fixture(ring_mock): +def requests_mock_fixture(): """Fixture to provide a requests mocker.""" with requests_mock.mock() as mock: # Note all devices have an id of 987652, but a different device_id. diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 5a04017f54b..4ca83b2451b 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,6 +1,5 @@ """The tests for the Ring binary sensor platform.""" from asyncio import run_coroutine_threadsafe -import os import unittest from unittest.mock import patch @@ -9,12 +8,7 @@ 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, - mock_storage, -) +from tests.common import get_test_home_assistant, load_fixture, mock_storage from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG @@ -28,15 +22,9 @@ class TestRingBinarySensorSetup(unittest.TestCase): for device in devices: self.DEVICES.append(device) - def cleanup(self): - """Cleanup any data created from the tests.""" - if os.path.isfile(self.cache): - os.remove(self.cache) - def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() - self.cache = get_test_config_dir(base_ring.DEFAULT_CACHEDB) self.config = { "username": "foo", "password": "bar", @@ -46,7 +34,6 @@ class TestRingBinarySensorSetup(unittest.TestCase): def tearDown(self): """Stop everything that was started.""" self.hass.stop() - self.cleanup() @requests_mock.Mocker() def test_binary_sensor(self, mock): diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 46925069c31..5712106333f 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -18,8 +18,10 @@ async def test_form(hass): assert result["errors"] == {} with patch( - "homeassistant.components.ring.config_flow.Ring", - return_value=Mock(is_connected=True), + "homeassistant.components.ring.config_flow.Auth", + return_value=Mock( + fetch_token=Mock(return_value={"access_token": "mock-token"}) + ), ), patch( "homeassistant.components.ring.async_setup", return_value=mock_coro(True) ) as mock_setup, patch( @@ -34,6 +36,7 @@ async def test_form(hass): assert result2["title"] == "hello@home-assistant.io" assert result2["data"] == { "username": "hello@home-assistant.io", + "token": {"access_token": "mock-token"}, } await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 @@ -47,7 +50,8 @@ async def test_form_invalid_auth(hass): ) with patch( - "homeassistant.components.ring.config_flow.Ring", side_effect=InvalidAuth, + "homeassistant.components.ring.config_flow.Auth.fetch_token", + side_effect=InvalidAuth, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index cfc19da78bf..809c71562c0 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -2,7 +2,6 @@ from asyncio import run_coroutine_threadsafe from copy import deepcopy from datetime import timedelta -import os import unittest import requests_mock @@ -10,7 +9,7 @@ import requests_mock from homeassistant import setup import homeassistant.components.ring as ring -from tests.common import get_test_config_dir, get_test_home_assistant, load_fixture +from tests.common import get_test_home_assistant, load_fixture ATTRIBUTION = "Data provided by Ring.com" @@ -22,21 +21,14 @@ VALID_CONFIG = { class TestRing(unittest.TestCase): """Tests the Ring component.""" - def cleanup(self): - """Cleanup any data created from the tests.""" - if os.path.isfile(self.cache): - os.remove(self.cache) - def setUp(self): """Initialize values for this test case class.""" self.hass = get_test_home_assistant() - self.cache = get_test_config_dir(ring.DEFAULT_CACHEDB) self.config = VALID_CONFIG def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - self.cleanup() @requests_mock.Mocker() def test_setup(self, mock): diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 0102020e3c2..039c9d0625f 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the Ring sensor platform.""" from asyncio import run_coroutine_threadsafe -import os import unittest from unittest.mock import patch @@ -10,12 +9,7 @@ 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, - mock_storage, -) +from tests.common import get_test_home_assistant, load_fixture, mock_storage from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG @@ -29,15 +23,9 @@ class TestRingSensorSetup(unittest.TestCase): for device in devices: self.DEVICES.append(device) - def cleanup(self): - """Cleanup any data created from the tests.""" - if os.path.isfile(self.cache): - os.remove(self.cache) - def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() - self.cache = get_test_config_dir(base_ring.DEFAULT_CACHEDB) self.config = { "username": "foo", "password": "bar", @@ -55,7 +43,6 @@ class TestRingSensorSetup(unittest.TestCase): def tearDown(self): """Stop everything that was started.""" self.hass.stop() - self.cleanup() @requests_mock.Mocker() def test_sensor(self, mock): @@ -97,6 +84,13 @@ class TestRingSensorSetup(unittest.TestCase): ).result() for device in self.DEVICES: + # Mimick add to hass + device.hass = self.hass + run_coroutine_threadsafe( + device.async_added_to_hass(), self.hass.loop, + ).result() + + # Entity update data from ring data device.update() if device.name == "Front Battery": expected_icon = icon_for_battery_level( @@ -104,18 +98,12 @@ class TestRingSensorSetup(unittest.TestCase): ) assert device.icon == expected_icon assert 80 == device.state - assert "hp_cam_v1" == device.device_state_attributes["kind"] - assert "stickup_cams" == device.device_state_attributes["type"] if device.name == "Front Door Battery": assert 100 == device.state - assert "lpd_v1" == device.device_state_attributes["kind"] - assert "chimes" != device.device_state_attributes["type"] if device.name == "Downstairs Volume": assert 2 == device.state - assert "1.2.3" == device.device_state_attributes["firmware"] assert "ring_mock_wifi" == device.device_state_attributes["wifi_name"] assert "mdi:bell-ring" == device.icon - assert "chimes" == device.device_state_attributes["type"] if device.name == "Front Door Last Activity": assert not device.device_state_attributes["answered"] assert "America/New_York" == device.device_state_attributes["timezone"]