From 0a7919a279cc99ad834486679dcc5ee83d0a0f9f Mon Sep 17 00:00:00 2001 From: tetienne Date: Tue, 11 Jun 2019 16:45:34 +0100 Subject: [PATCH] Somfy open api (#19548) * CREATE Somfy component * CREATE cover Somfy platform * USE somfy id as unique id * UPDATE all the devices in one call to limit the number of call * FIX Don't load devices if not yet configured * IMP Replace configurator by a simple notification * ADD log in case state does not match * IMP wording * REMOVE debug stuf * ADD support for tilt position * UPDATE requirements * FIX Use code instead of authorization response - Will allow to setup Somfy without https * HANDLE stateless devices (Somfy RTS) * FIX import locally 3rd party library * UPDATE pymfy to 0.4.3 * ADD missing docstring * FIX For Somfy 100 means closed and 0 opened * FIX position can be None * ENHANCE error management when error 500 occurs at setup * FIX indent * ROLLBACK tilt modification - See https://community.home-assistant.io/t/somfy-tahoma-official-api/61448/90?u=tetienne * FIX Look for capability instead of state * DON'T use exception to test if a feature is available * UPDATE dependency * ADD device_info property * AVOID object creation in each method * REMOVE unused constants * ADD missing doc * IMP Only make one call to add_entities * USE dict[key] instead of get method * IMP Don't pass hass object to the entities * FIX Don't end logging messages with period * USE config entries instead of a cache file * IMPLEMENT async_unload_entry * CONSOLIDATE package - see home-assistant/architecture#124 * UPDATE to pymfy 0.5.1 * SIMPLIFY config flow * ADD French translation * FIX 80 vs 79 max length * ABORT flow asap * FIX A tupple was returned * MIGRATE to manifest.json * ADD a placeholder async_setup_platform coroutine - It's currently required and expected by the platform helper. * FIX codeowner * ADD missing translations file * USE new external step * UPGRADE pymfy version * Close Somfy tab automatically * ADD manufacturer - Somfy only for the moment. * HANDLE missing code or state in Somfy request * REMOVE unused strings * DECLARE somfy component to use config_flow * APPLY static check remarks * FIX async method cannot be called from sync context * FIX only unload what has been loaded during entry setup * DON't catch them all * DON'T log full stacktrace * ABORT conflig flow if configuration missing * OMIT Somfy files for coverage * ADD tests about Somfy config flow * ADD pymfy to the test dependencies --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/somfy/.translations/en.json | 13 ++ .../components/somfy/.translations/fr.json | 13 ++ homeassistant/components/somfy/__init__.py | 160 ++++++++++++++++++ homeassistant/components/somfy/config_flow.py | 146 ++++++++++++++++ homeassistant/components/somfy/const.py | 5 + homeassistant/components/somfy/cover.py | 114 +++++++++++++ homeassistant/components/somfy/manifest.json | 13 ++ homeassistant/components/somfy/strings.json | 13 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/somfy/__init__.py | 1 + tests/components/somfy/test_config_flow.py | 77 +++++++++ 16 files changed, 565 insertions(+) create mode 100644 homeassistant/components/somfy/.translations/en.json create mode 100644 homeassistant/components/somfy/.translations/fr.json create mode 100644 homeassistant/components/somfy/__init__.py create mode 100644 homeassistant/components/somfy/config_flow.py create mode 100644 homeassistant/components/somfy/const.py create mode 100644 homeassistant/components/somfy/cover.py create mode 100644 homeassistant/components/somfy/manifest.json create mode 100644 homeassistant/components/somfy/strings.json create mode 100644 tests/components/somfy/__init__.py create mode 100644 tests/components/somfy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index aea77eb9977..fcdcb23809b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -561,6 +561,7 @@ omit = homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py + homeassistant/components/somfy/* homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py homeassistant/components/songpal/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 58021038d21..e0756e41932 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -223,6 +223,7 @@ homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smtp/* @fabaff homeassistant/components/solaredge_local/* @drobtravels homeassistant/components/solax/* @squishykid +homeassistant/components/somfy/* @tetienne homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff homeassistant/components/spider/* @peternijssen diff --git a/homeassistant/components/somfy/.translations/en.json b/homeassistant/components/somfy/.translations/en.json new file mode 100644 index 00000000000..d4155915636 --- /dev/null +++ b/homeassistant/components/somfy/.translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Somfy account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/fr.json b/homeassistant/components/somfy/.translations/fr.json new file mode 100644 index 00000000000..6367e411552 --- /dev/null +++ b/homeassistant/components/somfy/.translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Somfy.", + "authorize_url_timeout": "Durée expirée pour la génération de l'url d'autorisation.", + "missing_configuration": "Le composant Somfy n'est pas configuré. Merci de suivre la documentation." + }, + "create_entry": { + "default": "Authentification réussie avec Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py new file mode 100644 index 00000000000..c725bb47815 --- /dev/null +++ b/homeassistant/components/somfy/__init__.py @@ -0,0 +1,160 @@ +""" +Support for Somfy hubs. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/somfy/ +""" +import logging +from datetime import timedelta +from functools import partial + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from homeassistant.components.somfy import config_flow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +API = 'api' + +DEVICES = 'devices' + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +DOMAIN = 'somfy' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +SOMFY_AUTH_CALLBACK_PATH = '/auth/somfy/callback' +SOMFY_AUTH_START = '/auth/somfy' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + +SOMFY_COMPONENTS = ['cover'] + + +async def async_setup(hass, config): + """Set up the Somfy component.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = {} + + config_flow.register_flow_implementation( + hass, config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET]) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + )) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Somfy from a config entry.""" + def token_saver(token): + _LOGGER.debug('Saving updated token') + entry.data[CONF_TOKEN] = token + update_entry = partial( + hass.config_entries.async_update_entry, + data={**entry.data} + ) + hass.add_job(update_entry, entry) + + # Force token update. + from pymfy.api.somfy_api import SomfyApi + hass.data[DOMAIN][API] = SomfyApi( + entry.data['refresh_args']['client_id'], + entry.data['refresh_args']['client_secret'], + token=entry.data[CONF_TOKEN], + token_updater=token_saver + ) + + await update_all_devices(hass) + + for component in SOMFY_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 a config entry.""" + hass.data[DOMAIN].pop(API, None) + return True + + +class SomfyEntity(Entity): + """Representation of a generic Somfy device.""" + + def __init__(self, device, api): + """Initialize the Somfy device.""" + self.device = device + self.api = api + + @property + def unique_id(self): + """Return the unique id base on the id returned by Somfy.""" + return self.device.id + + @property + def name(self): + """Return the name of the device.""" + return self.device.name + + @property + def device_info(self): + """Return device specific attributes. + + Implemented by platform classes. + """ + return { + 'identifiers': {(DOMAIN, self.unique_id)}, + 'name': self.name, + 'model': self.device.type, + 'via_hub': (DOMAIN, self.device.site_id), + # For the moment, Somfy only returns their own device. + 'manufacturer': 'Somfy' + } + + async def async_update(self): + """Update the device with the latest data.""" + await update_all_devices(self.hass) + devices = self.hass.data[DOMAIN][DEVICES] + self.device = next((d for d in devices if d.id == self.device.id), + self.device) + + def has_capability(self, capability): + """Test if device has a capability.""" + capabilities = self.device.capabilities + return bool([c for c in capabilities if c.name == capability]) + + +@Throttle(MIN_TIME_BETWEEN_UPDATES) +async def update_all_devices(hass): + """Update all the devices.""" + from requests import HTTPError + try: + data = hass.data[DOMAIN] + data[DEVICES] = await hass.async_add_executor_job( + data[API].get_devices) + except HTTPError: + _LOGGER.warning("Cannot update devices") + return False + return True diff --git a/homeassistant/components/somfy/config_flow.py b/homeassistant/components/somfy/config_flow.py new file mode 100644 index 00000000000..0c29c037ba3 --- /dev/null +++ b/homeassistant/components/somfy/config_flow.py @@ -0,0 +1,146 @@ +"""Config flow for Somfy.""" +import asyncio +import logging + +import async_timeout + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback +from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN + +AUTH_CALLBACK_PATH = '/auth/somfy/callback' +AUTH_CALLBACK_NAME = 'auth:somfy:callback' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, client_id, client_secret): + """Register a flow implementation. + + client_id: Client id. + client_secret: Client secret. + """ + hass.data[DOMAIN][CLIENT_ID] = client_id + hass.data[DOMAIN][CLIENT_SECRET] = client_secret + + +@config_entries.HANDLERS.register('somfy') +class SomfyFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Instantiate config flow.""" + self.code = None + + async def async_step_import(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + return await self.async_step_auth() + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if DOMAIN not in self.hass.data: + return self.async_abort(reason='missing_configuration') + + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None): + """Create an entry for auth.""" + # Flow has been triggered from Somfy website + if user_input: + return await self.async_step_code(user_input) + + try: + with async_timeout.timeout(10): + url, _ = await self._get_authorization_url() + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + + return self.async_external_step( + step_id='auth', + url=url + ) + + async def _get_authorization_url(self): + """Get Somfy authorization url.""" + from pymfy.api.somfy_api import SomfyApi + client_id = self.hass.data[DOMAIN][CLIENT_ID] + client_secret = self.hass.data[DOMAIN][CLIENT_SECRET] + redirect_uri = '{}{}'.format( + self.hass.config.api.base_url, AUTH_CALLBACK_PATH) + api = SomfyApi(client_id, client_secret, redirect_uri) + + self.hass.http.register_view(SomfyAuthCallbackView()) + # Thanks to the state, we can forward the flow id to Somfy that will + # add it in the callback. + return await self.hass.async_add_executor_job( + api.get_authorization_url, self.flow_id) + + async def async_step_code(self, code): + """Received code for authentication.""" + self.code = code + return self.async_external_step_done(next_step_id="creation") + + async def async_step_creation(self, user_input=None): + """Create Somfy api and entries.""" + client_id = self.hass.data[DOMAIN][CLIENT_ID] + client_secret = self.hass.data[DOMAIN][CLIENT_SECRET] + code = self.code + from pymfy.api.somfy_api import SomfyApi + redirect_uri = '{}{}'.format( + self.hass.config.api.base_url, AUTH_CALLBACK_PATH) + api = SomfyApi(client_id, client_secret, redirect_uri) + token = await self.hass.async_add_executor_job(api.request_token, None, + code) + _LOGGER.info('Successfully authenticated Somfy') + return self.async_create_entry( + title='Somfy', + data={ + 'token': token, + 'refresh_args': { + 'client_id': client_id, + 'client_secret': client_secret + } + }, + ) + + +class SomfyAuthCallbackView(HomeAssistantView): + """Somfy Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + @staticmethod + async def get(request): + """Receive authorization code.""" + from aiohttp import web_response + + if 'code' not in request.query or 'state' not in request.query: + return web_response.Response( + text="Missing code or state parameter in " + request.url + ) + + hass = request.app['hass'] + hass.async_create_task( + hass.config_entries.flow.async_configure( + flow_id=request.query['state'], + user_input=request.query['code'], + )) + + return web_response.Response( + headers={ + 'content-type': 'text/html' + }, + text="" + ) diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py new file mode 100644 index 00000000000..3d7029d56f6 --- /dev/null +++ b/homeassistant/components/somfy/const.py @@ -0,0 +1,5 @@ +"""Define constants for the Somfy component.""" + +DOMAIN = 'somfy' +CLIENT_ID = 'client_id' +CLIENT_SECRET = 'client_secret' diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py new file mode 100644 index 00000000000..7b4e53f63a7 --- /dev/null +++ b/homeassistant/components/somfy/cover.py @@ -0,0 +1,114 @@ +""" +Support for Somfy Covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.somfy/ +""" + +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, \ + ATTR_TILT_POSITION +from homeassistant.components.somfy import DOMAIN, SomfyEntity, DEVICES, API + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Somfy cover platform.""" + def get_covers(): + """Retrieve covers.""" + from pymfy.api.devices.category import Category + + categories = {Category.ROLLER_SHUTTER.value, + Category.INTERIOR_BLIND.value, + Category.EXTERIOR_BLIND.value} + + devices = hass.data[DOMAIN][DEVICES] + + return [SomfyCover(cover, hass.data[DOMAIN][API]) for cover in + devices if + categories & set(cover.categories)] + + async_add_entities(await hass.async_add_executor_job(get_covers), True) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up platform. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +class SomfyCover(SomfyEntity, CoverDevice): + """Representation of a Somfy cover device.""" + + def __init__(self, device, api): + """Initialize the Somfy device.""" + from pymfy.api.devices.blind import Blind + super().__init__(device, api) + self.cover = Blind(self.device, self.api) + + async def async_update(self): + """Update the device with the latest data.""" + from pymfy.api.devices.blind import Blind + await super().async_update() + self.cover = Blind(self.device, self.api) + + def close_cover(self, **kwargs): + """Close the cover.""" + self.cover.close() + + def open_cover(self, **kwargs): + """Open the cover.""" + self.cover.open() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.cover.stop() + + def set_cover_position(self, **kwargs): + """Move the cover shutter to a specific position.""" + self.cover.set_position(100 - kwargs[ATTR_POSITION]) + + @property + def current_cover_position(self): + """Return the current position of cover shutter.""" + position = None + if self.has_capability('position'): + position = 100 - self.cover.get_position() + return position + + @property + def is_closed(self): + """Return if the cover is closed.""" + is_closed = None + if self.has_capability('position'): + is_closed = self.cover.is_closed() + return is_closed + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + orientation = None + if self.has_capability('rotation'): + orientation = 100 - self.cover.orientation + return orientation + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + self.cover.orientation = kwargs[ATTR_TILT_POSITION] + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.cover.orientation = 100 + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + self.cover.orientation = 0 + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + self.cover.stop() diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json new file mode 100644 index 00000000000..02eab03c8bb --- /dev/null +++ b/homeassistant/components/somfy/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "somfy", + "name": "Somfy Open API", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/somfy", + "dependencies": [], + "codeowners": [ + "@tetienne" + ], + "requirements": [ + "pymfy==0.5.2" + ] +} \ No newline at end of file diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json new file mode 100644 index 00000000000..d4155915636 --- /dev/null +++ b/homeassistant/components/somfy/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Somfy account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9b789af473e..296c620cd7d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -43,6 +43,7 @@ FLOWS = [ "simplisafe", "smartthings", "smhi", + "somfy", "sonos", "tellduslive", "toon", diff --git a/requirements_all.txt b/requirements_all.txt index 7d640e43f5b..29bd06de1d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1210,6 +1210,9 @@ pymailgunner==1.4 # homeassistant.components.mediaroom pymediaroom==0.6.4 +# homeassistant.components.somfy +pymfy==0.5.2 + # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 959b630317c..d7d683ccf6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,6 +250,9 @@ pyiqvia==0.2.1 # homeassistant.components.litejet pylitejet==0.1 +# homeassistant.components.somfy +pymfy==0.5.2 + # homeassistant.components.monoprice pymonoprice==0.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7cf9459635c..05fa9ed3ac6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -107,6 +107,7 @@ TEST_REQUIREMENTS = ( 'pyhomematic', 'pyiqvia', 'pylitejet', + 'pymfy', 'pymonoprice', 'pynx584', 'pyopenuv', diff --git a/tests/components/somfy/__init__.py b/tests/components/somfy/__init__.py new file mode 100644 index 00000000000..05f5cbcf4f0 --- /dev/null +++ b/tests/components/somfy/__init__.py @@ -0,0 +1 @@ +"""Tests for the Somfy component.""" diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py new file mode 100644 index 00000000000..4184e984d05 --- /dev/null +++ b/tests/components/somfy/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for the Somfy config flow.""" +import asyncio +from unittest.mock import Mock, patch + +from pymfy.api.somfy_api import SomfyApi + +from homeassistant import data_entry_flow +from homeassistant.components.somfy import config_flow, DOMAIN +from homeassistant.components.somfy.config_flow import \ + register_flow_implementation +from tests.common import MockConfigEntry, mock_coro + +CLIENT_SECRET_VALUE = "5678" + +CLIENT_ID_VALUE = "1234" + +AUTH_URL = 'http://somfy.com' + + +async def test_abort_if_no_configuration(hass): + """Check flow abort when no configuration.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'missing_configuration' + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow(hass): + """Check classic use case.""" + hass.data[DOMAIN] = {} + register_flow_implementation(hass, CLIENT_ID_VALUE, CLIENT_SECRET_VALUE) + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + hass.config.api = Mock(base_url='https://example.com') + flow._get_authorization_url = Mock( + return_value=mock_coro((AUTH_URL, 'state'))) + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result['url'] == AUTH_URL + result = await flow.async_step_auth("my_super_code") + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE + assert result['step_id'] == 'creation' + assert flow.code == 'my_super_code' + with patch.object(SomfyApi, 'request_token', + return_value={"access_token": "super_token"}): + result = await flow.async_step_creation() + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['refresh_args'] == { + 'client_id': CLIENT_ID_VALUE, + 'client_secret': CLIENT_SECRET_VALUE + } + assert result['title'] == 'Somfy' + assert result['data']['token'] == {"access_token": "super_token"} + + +async def test_abort_if_authorization_timeout(hass): + """Check Somfy authorization timeout.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + flow._get_authorization_url = Mock(side_effect=asyncio.TimeoutError) + result = await flow.async_step_auth() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout'