From bb4b7c96d0ff88dcb57a590d1e127a2d0b829b8d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 27 Feb 2022 22:38:39 +0100 Subject: [PATCH] Migrate entry unique id for Sensibo (#67119) --- homeassistant/components/sensibo/__init__.py | 28 +++- .../components/sensibo/config_flow.py | 61 +++----- homeassistant/components/sensibo/strings.json | 7 +- .../components/sensibo/translations/en.json | 5 +- homeassistant/components/sensibo/util.py | 49 +++++++ tests/components/sensibo/test_config_flow.py | 136 ++++++++++++++---- 6 files changed, 212 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/sensibo/util.py diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index b62482b60b5..ab8e4e85d39 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -1,11 +1,15 @@ """The sensibo component.""" from __future__ import annotations +from pysensibo.exceptions import AuthenticationError + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator +from .util import NoDevicesError, NoUsernameError, async_validate_api async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -28,3 +32,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: del hass.data[DOMAIN] return True return False + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + # Change entry unique id from api_key to username + if entry.version == 1: + api_key = entry.data[CONF_API_KEY] + + try: + new_unique_id = await async_validate_api(hass, api_key) + except (AuthenticationError, ConnectionError, NoDevicesError, NoUsernameError): + return False + + entry.version = 2 + + LOGGER.debug("Migrate Sensibo config entry unique id to %s", new_unique_id) + hass.config_entries.async_update_entry( + entry, + unique_id=new_unique_id, + ) + + return True diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index f970581e2a8..dca5ea62fe6 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -1,25 +1,16 @@ """Adds config flow for Sensibo integration.""" from __future__ import annotations -import asyncio -import logging - -import aiohttp -import async_timeout -from pysensibo import SensiboClient -from pysensibo.exceptions import AuthenticationError, SensiboError +from pysensibo.exceptions import AuthenticationError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import DEFAULT_NAME, DOMAIN, TIMEOUT - -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_NAME, DOMAIN +from .util import NoDevicesError, NoUsernameError, async_validate_api DATA_SCHEMA = vol.Schema( { @@ -28,39 +19,14 @@ DATA_SCHEMA = vol.Schema( ) -async def async_validate_api(hass: HomeAssistant, api_key: str) -> bool: - """Get data from API.""" - client = SensiboClient( - api_key, - session=async_get_clientsession(hass), - timeout=TIMEOUT, - ) - - try: - async with async_timeout.timeout(TIMEOUT): - if await client.async_get_devices(): - return True - except ( - aiohttp.ClientConnectionError, - asyncio.TimeoutError, - AuthenticationError, - SensiboError, - ) as err: - _LOGGER.error("Failed to get devices from Sensibo servers %s", err) - return False - - class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Sensibo integration.""" - VERSION = 1 + VERSION = 2 async def async_step_import(self, config: dict) -> FlowResult: """Import a configuration from config.yaml.""" - self.context.update( - {"title_placeholders": {"Sensibo": f"YAML import {DOMAIN}"}} - ) return await self.async_step_user(user_input=config) async def async_step_user(self, user_input=None) -> FlowResult: @@ -71,17 +37,24 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: api_key = user_input[CONF_API_KEY] + try: + username = await async_validate_api(self.hass, api_key) + except AuthenticationError: + errors["base"] = "invalid_auth" + except ConnectionError: + errors["base"] = "cannot_connect" + except NoDevicesError: + errors["base"] = "no_devices" + except NoUsernameError: + errors["base"] = "no_username" + else: + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() - await self.async_set_unique_id(api_key) - self._abort_if_unique_id_configured() - - validate = await async_validate_api(self.hass, api_key) - if validate: return self.async_create_entry( title=DEFAULT_NAME, data={CONF_API_KEY: api_key}, ) - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 22751964999..9b035bc7f05 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -3,8 +3,11 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, - "error":{ - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_devices": "No devices discovered", + "no_username": "Could not get username" }, "step": { "user": { diff --git a/homeassistant/components/sensibo/translations/en.json b/homeassistant/components/sensibo/translations/en.json index d3ee9fb1336..102ffa35879 100644 --- a/homeassistant/components/sensibo/translations/en.json +++ b/homeassistant/components/sensibo/translations/en.json @@ -4,7 +4,10 @@ "already_configured": "Account is already configured" }, "error": { - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "no_devices": "No devices discovered", + "no_username": "Could not get username" }, "step": { "user": { diff --git a/homeassistant/components/sensibo/util.py b/homeassistant/components/sensibo/util.py new file mode 100644 index 00000000000..fda9d4a210e --- /dev/null +++ b/homeassistant/components/sensibo/util.py @@ -0,0 +1,49 @@ +"""Utils for Sensibo integration.""" +from __future__ import annotations + +import async_timeout +from pysensibo import SensiboClient +from pysensibo.exceptions import AuthenticationError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import LOGGER, SENSIBO_ERRORS, TIMEOUT + + +async def async_validate_api(hass: HomeAssistant, api_key: str) -> str: + """Get data from API.""" + client = SensiboClient( + api_key, + session=async_get_clientsession(hass), + timeout=TIMEOUT, + ) + + try: + async with async_timeout.timeout(TIMEOUT): + device_query = await client.async_get_devices() + user_query = await client.async_get_me() + except AuthenticationError as err: + LOGGER.error("Could not authenticate on Sensibo servers %s", err) + raise AuthenticationError from err + except SENSIBO_ERRORS as err: + LOGGER.error("Failed to get information from Sensibo servers %s", err) + raise ConnectionError from err + + devices = device_query["result"] + user = user_query["result"].get("username") + if not devices: + LOGGER.error("Could not retrieve any devices from Sensibo servers") + raise NoDevicesError + if not user: + LOGGER.error("Could not retrieve username from Sensibo servers") + raise NoUsernameError + return user + + +class NoDevicesError(Exception): + """No devices from Sensibo api.""" + + +class NoUsernameError(Exception): + """No username from Sensibo api.""" diff --git a/tests/components/sensibo/test_config_flow.py b/tests/components/sensibo/test_config_flow.py index 9c59fc70763..9cc96c1c04b 100644 --- a/tests/components/sensibo/test_config_flow.py +++ b/tests/components/sensibo/test_config_flow.py @@ -5,7 +5,7 @@ import asyncio from unittest.mock import patch import aiohttp -from pysensibo import AuthenticationError, SensiboError +from pysensibo.exceptions import AuthenticationError, SensiboError import pytest from homeassistant import config_entries @@ -22,11 +22,6 @@ from tests.common import MockConfigEntry DOMAIN = "sensibo" -def devices(): - """Return list of test devices.""" - return (yield from [{"id": "xyzxyz"}, {"id": "abcabc"}]) - - async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -38,8 +33,11 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", - return_value=devices(), + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", + return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_me", + return_value={"result": {"username": "username"}}, ), patch( "homeassistant.components.sensibo.async_setup_entry", return_value=True, @@ -53,6 +51,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["version"] == 2 assert result2["data"] == { "api_key": "1234567890", } @@ -64,8 +63,11 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: """Test a successful import of yaml.""" with patch( - "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", - return_value=devices(), + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", + return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_me", + return_value={"result": {"username": "username"}}, ), patch( "homeassistant.components.sensibo.async_setup_entry", return_value=True, @@ -95,15 +97,18 @@ async def test_import_flow_already_exist(hass: HomeAssistant) -> None: data={ CONF_API_KEY: "1234567890", }, - unique_id="1234567890", + unique_id="username", ).add_to_hass(hass) with patch( "homeassistant.components.sensibo.async_setup_entry", return_value=True, ), patch( - "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", - return_value=devices(), + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", + return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_me", + return_value={"result": {"username": "username"}}, ): result3 = await hass.config_entries.flow.async_init( DOMAIN, @@ -119,33 +124,112 @@ async def test_import_flow_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "error_message", + "error_message, p_error", [ - (aiohttp.ClientConnectionError), - (asyncio.TimeoutError), - (AuthenticationError), - (SensiboError), + (aiohttp.ClientConnectionError, "cannot_connect"), + (asyncio.TimeoutError, "cannot_connect"), + (AuthenticationError, "invalid_auth"), + (SensiboError, "cannot_connect"), ], ) -async def test_flow_fails(hass: HomeAssistant, error_message) -> None: +async def test_flow_fails( + hass: HomeAssistant, error_message: Exception, p_error: str +) -> None: """Test config flow errors.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] == RESULT_TYPE_FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER with patch( - "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", side_effect=error_message, ): - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_API_KEY: "1234567890", }, ) - assert result4["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", + return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_me", + return_value={"result": {"username": "username"}}, + ), patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567891", + }, + ) + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Sensibo" + assert result3["data"] == { + "api_key": "1234567891", + } + + +async def test_flow_get_no_devices(hass: HomeAssistant) -> None: + """Test config flow get no devices from api.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", + return_value={"result": []}, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_me", + return_value={"result": {}}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result2["errors"] == {"base": "no_devices"} + + +async def test_flow_get_no_username(hass: HomeAssistant) -> None: + """Test config flow get no username from api.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", + return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_me", + return_value={"result": {}}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result2["errors"] == {"base": "no_username"}