From f9de8fb49ab4d31b0f1547d1dcba95e2a3a341ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 13 Sep 2021 21:57:06 +0200 Subject: [PATCH] Surepetcare config flow (#56127) Co-authored-by: J. Nick Koston --- .../components/surepetcare/__init__.py | 50 ++++-- .../components/surepetcare/binary_sensor.py | 8 +- .../components/surepetcare/config_flow.py | 85 +++++++++++ .../components/surepetcare/manifest.json | 14 +- .../components/surepetcare/sensor.py | 6 +- .../components/surepetcare/strings.json | 20 +++ homeassistant/generated/config_flows.py | 1 + tests/components/surepetcare/conftest.py | 1 + .../surepetcare/test_config_flow.py | 144 ++++++++++++++++++ 9 files changed, 303 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/surepetcare/config_flow.py create mode 100644 homeassistant/components/surepetcare/strings.json create mode 100644 tests/components/surepetcare/test_config_flow.py diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index f04af0dd795b..5462cfc954c1 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -9,7 +9,14 @@ from surepy.enums import LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -61,14 +68,28 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sure Petcare integration.""" - conf = config[DOMAIN] + 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=config[DOMAIN], + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sure Petcare from a config entry.""" hass.data.setdefault(DOMAIN, {}) try: surepy = Surepy( - conf[CONF_USERNAME], - conf[CONF_PASSWORD], - auth_token=None, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + auth_token=entry.data[CONF_TOKEN], api_timeout=SURE_API_TIMEOUT, session=async_get_clientsession(hass), ) @@ -94,14 +115,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: update_interval=SCAN_INTERVAL, ) - hass.data[DOMAIN] = coordinator - await coordinator.async_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() - # load platforms - for platform in PLATFORMS: - hass.async_create_task( - hass.helpers.discovery.async_load_platform(platform, DOMAIN, {}, config) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) lock_states = { LockState.UNLOCKED.name.lower(): surepy.sac.unlock, @@ -138,3 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 0c223ae3ac1c..b61eae12a7ed 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -23,16 +23,12 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: +async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" - if discovery_info is None: - return entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] - coordinator: DataUpdateCoordinator = hass.data[DOMAIN] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py new file mode 100644 index 000000000000..e2e5f07f05ee --- /dev/null +++ b/homeassistant/components/surepetcare/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for Sure Petcare integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from surepy import Surepy +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, SURE_API_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + surepy = Surepy( + data[CONF_USERNAME], + data[CONF_PASSWORD], + auth_token=None, + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(hass), + ) + + token = await surepy.sac.get_token() + + return {CONF_TOKEN: token} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sure Petcare.""" + + VERSION = 1 + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return await self.async_step_user(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except SurePetcareAuthenticationError: + errors["base"] = "invalid_auth" + except SurePetcareError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + user_input[CONF_TOKEN] = info[CONF_TOKEN] + return self.async_create_entry( + title="Sure Petcare", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index 9fb35ac91e55..466f73644b60 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -2,7 +2,13 @@ "domain": "surepetcare", "name": "Sure Petcare", "documentation": "https://www.home-assistant.io/integrations/surepetcare", - "codeowners": ["@benleb", "@danielhiversen"], - "requirements": ["surepy==0.7.1"], - "iot_class": "cloud_polling" -} + "codeowners": [ + "@benleb", + "@danielhiversen" + ], + "requirements": [ + "surepy==0.7.1" + ], + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 53d5d985e413..0122dc2905d4 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -19,14 +19,12 @@ from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, entry, async_add_entities): """Set up Sure PetCare Flaps sensors.""" - if discovery_info is None: - return entities: list[SurepyEntity] = [] - coordinator: DataUpdateCoordinator = hass.data[DOMAIN] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json new file mode 100644 index 000000000000..f5e2f6f173b9 --- /dev/null +++ b/homeassistant/components/surepetcare/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f6fac775b2d7..738a2d1172ae 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -265,6 +265,7 @@ FLOWS = [ "srp_energy", "starline", "subaru", + "surepetcare", "switcher_kis", "syncthing", "syncthru", diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index cecdaababa92..dd1cd19aa0e4 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -19,4 +19,5 @@ async def surepetcare(): client = mock_client_class.return_value client.resources = {} client.call = _mock_call + client.get_token.return_value = "token" yield client diff --git a/tests/components/surepetcare/test_config_flow.py b/tests/components/surepetcare/test_config_flow.py new file mode 100644 index 000000000000..d397c9b121aa --- /dev/null +++ b/tests/components/surepetcare/test_config_flow.py @@ -0,0 +1,144 @@ +"""Test the Sure Petcare config flow.""" +from unittest.mock import NonCallableMagicMock, patch + +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError + +from homeassistant import config_entries, setup +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, surepetcare: NonCallableMagicMock) -> None: + """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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.surepetcare.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Sure Petcare" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "token": "token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareAuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "surepy.client.SureAPIClient.get_token", + side_effect=SurePetcareError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "surepy.client.SureAPIClient.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists( + hass, surepetcare: NonCallableMagicMock +) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="surepetcare", + data={ + "username": "test-username", + "password": "test-password", + }, + unique_id="test-username", + ) + first_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.surepetcare.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "username": "test-username", + "password": "test-password", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"