From 30623126494b4c1772cc74b72d3c6e25be87a63d Mon Sep 17 00:00:00 2001 From: Baptiste Candellier Date: Sun, 5 Jul 2020 21:20:51 +0200 Subject: [PATCH] Add config flow + async support for SmartHab integration (#34387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Setup barebones SmartHab config flow * Setup authentication flow * Make setup async, add config flow receivers * Add French translation * Fix async issues * Address review comments (thanks bdraco!) * Fix unloading entries * Migrate translations dir according to warning * Create list of components * Fix pylint false positive * Fix bad copy-pastes 🤭 * Add async support to SmartHab component * Address review comments (bdraco) * Fix pylint * Improve exception handling (bdraco) * Apply suggestions from code review (bdraco) Co-authored-by: J. Nick Koston * Don't log exceptions manually, fix error * Reduce repeated lines in async_step_user (bdraco) * Remove useless else (pylint) * Remove broad exception handler * Create strings.json + remove fr i18n * Write tests for smarthab config flow * Test import flow * Fix import test * Update homeassistant/components/smarthab/config_flow.py Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .coveragerc | 4 +- homeassistant/components/smarthab/__init__.py | 65 ++++++--- .../components/smarthab/config_flow.py | 77 +++++++++++ homeassistant/components/smarthab/cover.py | 42 +++--- homeassistant/components/smarthab/light.py | 28 ++-- .../components/smarthab/manifest.json | 3 +- .../components/smarthab/strings.json | 19 +++ .../components/smarthab/translations/en.json | 19 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/smarthab/__init__.py | 1 + tests/components/smarthab/test_config_flow.py | 126 ++++++++++++++++++ 13 files changed, 330 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/smarthab/config_flow.py create mode 100644 homeassistant/components/smarthab/strings.json create mode 100644 homeassistant/components/smarthab/translations/en.json create mode 100644 tests/components/smarthab/__init__.py create mode 100644 tests/components/smarthab/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 5d9f7366090..984a9c173cb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -732,7 +732,9 @@ omit = homeassistant/components/smappee/sensor.py homeassistant/components/smappee/switch.py homeassistant/components/smarty/* - homeassistant/components/smarthab/* + homeassistant/components/smarthab/__init__.py + homeassistant/components/smarthab/cover.py + homeassistant/components/smarthab/light.py homeassistant/components/sms/* homeassistant/components/smtp/notify.py homeassistant/components/snapcast/* diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py index 778b5171ae4..82c550e0060 100644 --- a/homeassistant/components/smarthab/__init__.py +++ b/homeassistant/components/smarthab/__init__.py @@ -1,15 +1,19 @@ """Support for SmartHab device integration.""" +import asyncio import logging import pysmarthab import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.typing import HomeAssistantType DOMAIN = "smarthab" DATA_HUB = "hub" +COMPONENTS = ["light", "cover"] _LOGGER = logging.getLogger(__name__) @@ -26,34 +30,61 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config) -> bool: +async def async_setup(hass, config) -> bool: """Set up the SmartHab platform.""" + hass.data.setdefault(DOMAIN, {}) sh_conf = config.get(DOMAIN) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=sh_conf, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up config entry for SmartHab integration.""" + # Assign configuration variables - username = sh_conf[CONF_EMAIL] - password = sh_conf[CONF_PASSWORD] + username = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] # Setup connection with SmartHab API hub = pysmarthab.SmartHab() try: - hub.login(username, password) - except pysmarthab.RequestFailedException as ex: - _LOGGER.error("Error while trying to reach SmartHab API.") - _LOGGER.debug(ex, exc_info=True) - return False - - # Verify that passed in configuration works - if not hub.is_logged_in(): - _LOGGER.error("Could not authenticate with SmartHab API") - return False + await hub.async_login(username, password) + except pysmarthab.RequestFailedException: + _LOGGER.exception("Error while trying to reach SmartHab API") + raise ConfigEntryNotReady # Pass hub object to child platforms - hass.data[DOMAIN] = {DATA_HUB: hub} + hass.data[DOMAIN][entry.entry_id] = {DATA_HUB: hub} - load_platform(hass, "light", DOMAIN, None, config) - load_platform(hass, "cover", DOMAIN, None, config) + for component in COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload config entry from SmartHab integration.""" + + result = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in COMPONENTS + ] + ) + ) + + if result: + hass.data[DOMAIN].pop(entry.entry_id) + + return result diff --git a/homeassistant/components/smarthab/config_flow.py b/homeassistant/components/smarthab/config_flow.py new file mode 100644 index 00000000000..f0a1df88695 --- /dev/null +++ b/homeassistant/components/smarthab/config_flow.py @@ -0,0 +1,77 @@ +"""SmartHab configuration flow.""" +import logging + +import pysmarthab +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +# pylint: disable=unused-import +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SmartHabConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """SmartHab config flow.""" + + def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_EMAIL, default=user_input.get(CONF_EMAIL, "") + ): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self._show_setup_form(user_input, None) + + username = user_input[CONF_EMAIL] + password = user_input[CONF_PASSWORD] + + # Check if already configured + if self.unique_id is None: + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() + + # Setup connection with SmartHab API + hub = pysmarthab.SmartHab() + + try: + await hub.async_login(username, password) + + # Verify that passed in configuration works + if hub.is_logged_in(): + return self.async_create_entry( + title=username, data={CONF_EMAIL: username, CONF_PASSWORD: password} + ) + + errors["base"] = "wrong_login" + except pysmarthab.RequestFailedException: + _LOGGER.exception("Error while trying to reach SmartHab API") + errors["base"] = "service" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error during login") + errors["base"] = "unknown_error" + + return self._show_setup_form(user_input, errors) + + async def async_step_import(self, user_input): + """Handle import from legacy config.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/smarthab/cover.py b/homeassistant/components/smarthab/cover.py index 09b8a7435ee..4fc663fc3d8 100644 --- a/homeassistant/components/smarthab/cover.py +++ b/homeassistant/components/smarthab/cover.py @@ -7,6 +7,7 @@ from requests.exceptions import Timeout from homeassistant.components.cover import ( ATTR_POSITION, + DEVICE_CLASS_WINDOW, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, @@ -20,21 +21,17 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the SmartHab roller shutters platform.""" - - hub = hass.data[DOMAIN][DATA_HUB] - devices = hub.get_device_list() - - _LOGGER.debug("Found a total of %s devices", str(len(devices))) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up SmartHab covers from a config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id][DATA_HUB] entities = ( SmartHabCover(cover) - for cover in devices + for cover in await hub.async_get_device_list() if isinstance(cover, pysmarthab.Shutter) ) - add_entities(entities, True) + async_add_entities(entities, True) class SmartHabCover(CoverEntity): @@ -51,7 +48,7 @@ class SmartHabCover(CoverEntity): @property def name(self) -> str: - """Return the display name of this light.""" + """Return the display name of this cover.""" return self._cover.label @property @@ -65,12 +62,7 @@ class SmartHabCover(CoverEntity): @property def supported_features(self) -> int: """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - - if self.current_cover_position is not None: - supported_features |= SUPPORT_SET_POSITION - - return supported_features + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION @property def is_closed(self) -> bool: @@ -80,24 +72,24 @@ class SmartHabCover(CoverEntity): @property def device_class(self) -> str: """Return the class of this device, from component DEVICE_CLASSES.""" - return "window" + return DEVICE_CLASS_WINDOW - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" - self._cover.open() + await self._cover.async_open() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close cover.""" - self._cover.close() + await self._cover.async_close() - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self._cover.state = kwargs[ATTR_POSITION] + await self._cover.async_set_state(kwargs[ATTR_POSITION]) - def update(self): + async def async_update(self): """Fetch new state data for this cover.""" try: - self._cover.update() + await self._cover.async_update() except Timeout: _LOGGER.error( "Reached timeout while updating cover %s from API", self.entity_id diff --git a/homeassistant/components/smarthab/light.py b/homeassistant/components/smarthab/light.py index 8b608cfbd4f..9678930e977 100644 --- a/homeassistant/components/smarthab/light.py +++ b/homeassistant/components/smarthab/light.py @@ -14,19 +14,17 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the SmartHab lights platform.""" - - hub = hass.data[DOMAIN][DATA_HUB] - devices = hub.get_device_list() - - _LOGGER.debug("Found a total of %s devices", str(len(devices))) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up SmartHab lights from a config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id][DATA_HUB] entities = ( - SmartHabLight(light) for light in devices if isinstance(light, pysmarthab.Light) + SmartHabLight(light) + for light in await hub.async_get_device_list() + if isinstance(light, pysmarthab.Light) ) - add_entities(entities, True) + async_add_entities(entities, True) class SmartHabLight(LightEntity): @@ -51,18 +49,18 @@ class SmartHabLight(LightEntity): """Return true if light is on.""" return self._light.state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" - self._light.turn_on() + await self._light.async_turn_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._light.turn_off() + await self._light.async_turn_off() - def update(self): + async def async_update(self): """Fetch new state data for this light.""" try: - self._light.update() + await self._light.async_update() except Timeout: _LOGGER.error( "Reached timeout while updating light %s from API", self.entity_id diff --git a/homeassistant/components/smarthab/manifest.json b/homeassistant/components/smarthab/manifest.json index 141928d2d92..5c601cc9e21 100644 --- a/homeassistant/components/smarthab/manifest.json +++ b/homeassistant/components/smarthab/manifest.json @@ -2,6 +2,7 @@ "domain": "smarthab", "name": "SmartHab", "documentation": "https://www.home-assistant.io/integrations/smarthab", - "requirements": ["smarthab==0.20"], + "config_flow": true, + "requirements": ["smarthab==0.21"], "codeowners": ["@outadoc"] } diff --git a/homeassistant/components/smarthab/strings.json b/homeassistant/components/smarthab/strings.json new file mode 100644 index 00000000000..f27e359257f --- /dev/null +++ b/homeassistant/components/smarthab/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "service": "Error while trying to reach SmartHab. Service might be down. Check your connection.", + "wrong_login": "[%key:common::config_flow::error::invalid_auth%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "email": "[%key:common::config_flow::data::email%]" + }, + "description": "For technical reasons, be sure to use a secondary account specific to your Home Assistant setup. You can create one from the SmartHab application.", + "title": "Setup SmartHab" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/en.json b/homeassistant/components/smarthab/translations/en.json new file mode 100644 index 00000000000..f27e359257f --- /dev/null +++ b/homeassistant/components/smarthab/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "service": "Error while trying to reach SmartHab. Service might be down. Check your connection.", + "wrong_login": "[%key:common::config_flow::error::invalid_auth%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "email": "[%key:common::config_flow::data::email%]" + }, + "description": "For technical reasons, be sure to use a secondary account specific to your Home Assistant setup. You can create one from the SmartHab application.", + "title": "Setup SmartHab" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d2afc1f3630..c7838063187 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -140,6 +140,7 @@ FLOWS = [ "shopping_list", "simplisafe", "smappee", + "smarthab", "smartthings", "smhi", "sms", diff --git a/requirements_all.txt b/requirements_all.txt index c079e6f5c0e..1634e71967b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1975,7 +1975,7 @@ sleepyq==0.7 slixmpp==1.5.1 # homeassistant.components.smarthab -smarthab==0.20 +smarthab==0.21 # homeassistant.components.bh1750 # homeassistant.components.bme280 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0249008ab0a..e7834c665a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -852,6 +852,9 @@ simplisafe-python==9.2.0 # homeassistant.components.sleepiq sleepyq==0.7 +# homeassistant.components.smarthab +smarthab==0.21 + # homeassistant.components.smhi smhi-pkg==1.0.13 diff --git a/tests/components/smarthab/__init__.py b/tests/components/smarthab/__init__.py new file mode 100644 index 00000000000..0e393ee0f9e --- /dev/null +++ b/tests/components/smarthab/__init__.py @@ -0,0 +1 @@ +"""Tests for the SmartHab integration.""" diff --git a/tests/components/smarthab/test_config_flow.py b/tests/components/smarthab/test_config_flow.py new file mode 100644 index 00000000000..8e6d87a53cc --- /dev/null +++ b/tests/components/smarthab/test_config_flow.py @@ -0,0 +1,126 @@ +"""Test the SmartHab config flow.""" +import pysmarthab + +from homeassistant import config_entries, setup +from homeassistant.components.smarthab import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.async_mock import patch + + +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("pysmarthab.SmartHab.async_login"), patch( + "pysmarthab.SmartHab.is_logged_in", return_value=True + ), patch( + "homeassistant.components.smarthab.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.smarthab.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "mock@example.com" + assert result2["data"] == { + CONF_EMAIL: "mock@example.com", + CONF_PASSWORD: "test-password", + } + 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("pysmarthab.SmartHab.async_login"), patch( + "pysmarthab.SmartHab.is_logged_in", return_value=False + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "wrong_login"} + + +async def test_form_service_error(hass): + """Test we handle service errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pysmarthab.SmartHab.async_login", + side_effect=pysmarthab.RequestFailedException(42), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "service"} + + +async def test_form_unknown_error(hass): + """Test we handle unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pysmarthab.SmartHab.async_login", side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown_error"} + + +async def test_import(hass): + """Test import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + imported_conf = { + CONF_EMAIL: "mock@example.com", + CONF_PASSWORD: "test-password", + } + + with patch("pysmarthab.SmartHab.async_login"), patch( + "pysmarthab.SmartHab.is_logged_in", return_value=True + ), patch( + "homeassistant.components.smarthab.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.smarthab.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=imported_conf + ) + + assert result["type"] == "create_entry" + assert result["title"] == "mock@example.com" + assert result["data"] == { + CONF_EMAIL: "mock@example.com", + CONF_PASSWORD: "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1