From 4b54694c5c809008037565e56d98c8f70a453074 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Jan 2021 09:24:22 -1000 Subject: [PATCH] Add config flow for somfy_mylink (#44977) * Add config flow for somfy_mylink * fix typo --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/somfy_mylink/__init__.py | 128 +++++- .../components/somfy_mylink/config_flow.py | 203 +++++++++ .../components/somfy_mylink/const.py | 14 + .../components/somfy_mylink/cover.py | 138 +++++-- .../components/somfy_mylink/manifest.json | 9 +- .../components/somfy_mylink/strings.json | 44 ++ .../somfy_mylink/translations/en.json | 44 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/somfy_mylink/__init__.py | 1 + .../somfy_mylink/test_config_flow.py | 385 ++++++++++++++++++ 13 files changed, 920 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/somfy_mylink/config_flow.py create mode 100644 homeassistant/components/somfy_mylink/const.py create mode 100644 homeassistant/components/somfy_mylink/strings.json create mode 100644 homeassistant/components/somfy_mylink/translations/en.json create mode 100644 tests/components/somfy_mylink/__init__.py create mode 100644 tests/components/somfy_mylink/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index b3e58591bfa..2b08fc50336 100644 --- a/.coveragerc +++ b/.coveragerc @@ -838,7 +838,8 @@ omit = homeassistant/components/soma/cover.py homeassistant/components/soma/sensor.py homeassistant/components/somfy/* - homeassistant/components/somfy_mylink/* + homeassistant/components/somfy_mylink/__init__.py + homeassistant/components/somfy_mylink/cover.py homeassistant/components/sonos/* homeassistant/components/sony_projector/switch.py homeassistant/components/spc/* diff --git a/CODEOWNERS b/CODEOWNERS index 3a1f15c015d..9f6e08216b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -421,6 +421,7 @@ homeassistant/components/solarlog/* @Ernst79 homeassistant/components/solax/* @squishykid homeassistant/components/soma/* @ratsept homeassistant/components/somfy/* @tetienne +homeassistant/components/somfy_mylink/* @bdraco homeassistant/components/sonarr/* @ctalkington homeassistant/components/songpal/* @rytilahti @shenxn homeassistant/components/sonos/* @cgtobi diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 8106cde0c18..5427dee5916 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -1,18 +1,33 @@ """Component for the Somfy MyLink device supporting the Synergy API.""" +import asyncio +import logging + from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform -CONF_ENTITY_CONFIG = "entity_config" -CONF_SYSTEM_ID = "system_id" -CONF_REVERSE = "reverse" -CONF_DEFAULT_REVERSE = "default_reverse" -DATA_SOMFY_MYLINK = "somfy_mylink_data" -DOMAIN = "somfy_mylink" -SOMFY_MYLINK_COMPONENTS = ["cover"] +from .const import ( + CONF_DEFAULT_REVERSE, + CONF_ENTITY_CONFIG, + CONF_REVERSE, + CONF_SYSTEM_ID, + DATA_SOMFY_MYLINK, + DEFAULT_PORT, + DOMAIN, + MYLINK_ENTITY_IDS, + MYLINK_STATUS, + SOMFY_MYLINK_COMPONENTS, +) + +CONFIG_OPTIONS = (CONF_DEFAULT_REVERSE, CONF_ENTITY_CONFIG) +UNDO_UPDATE_LISTENER = "undo_update_listener" + +_LOGGER = logging.getLogger(__name__) def validate_entity_config(values): @@ -34,7 +49,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_SYSTEM_ID): cv.string, vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=44100): cv.port, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_DEFAULT_REVERSE, default=False): cv.boolean, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, } @@ -47,15 +62,94 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the MyLink platform.""" - host = config[DOMAIN][CONF_HOST] - port = config[DOMAIN][CONF_PORT] - system_id = config[DOMAIN][CONF_SYSTEM_ID] - entity_config = config[DOMAIN][CONF_ENTITY_CONFIG] - entity_config[CONF_DEFAULT_REVERSE] = config[DOMAIN][CONF_DEFAULT_REVERSE] - somfy_mylink = SomfyMyLinkSynergy(system_id, host, port) - hass.data[DATA_SOMFY_MYLINK] = somfy_mylink + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Somfy MyLink from a config entry.""" + _async_import_options_from_data_if_missing(hass, entry) + + config = entry.data + somfy_mylink = SomfyMyLinkSynergy( + config[CONF_SYSTEM_ID], config[CONF_HOST], config[CONF_PORT] + ) + + try: + mylink_status = await somfy_mylink.status_info() + except asyncio.TimeoutError as ex: + raise ConfigEntryNotReady( + "Unable to connect to the Somfy MyLink device, please check your settings" + ) from ex + + if "error" in mylink_status: + _LOGGER.error( + "mylink failed to setup because of an error: %s", + mylink_status.get("error", {}).get("message"), + ) + return False + + undo_listener = entry.add_update_listener(_async_update_listener) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_SOMFY_MYLINK: somfy_mylink, + MYLINK_STATUS: mylink_status, + MYLINK_ENTITY_IDS: [], + UNDO_UPDATE_LISTENER: undo_listener, + } + for component in SOMFY_MYLINK_COMPONENTS: hass.async_create_task( - async_load_platform(hass, component, DOMAIN, entity_config, config) + hass.config_entries.async_forward_entry_setup(entry, component) ) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +@callback +def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): + options = dict(entry.options) + data = dict(entry.data) + modified = False + + for importable_option in CONFIG_OPTIONS: + if importable_option not in options and importable_option in data: + options[importable_option] = data.pop(importable_option) + modified = True + + if modified: + hass.config_entries.async_update_entry(entry, data=data, options=options) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in SOMFY_MYLINK_COMPONENTS + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py new file mode 100644 index 00000000000..6f66c9899b4 --- /dev/null +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -0,0 +1,203 @@ +"""Config flow for Somfy MyLink integration.""" +import asyncio +import logging + +from somfy_mylink_synergy import SomfyMyLinkSynergy +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_HOST, CONF_PORT +from homeassistant.core import callback + +from .const import ( + CONF_DEFAULT_REVERSE, + CONF_ENTITY_CONFIG, + CONF_REVERSE, + CONF_SYSTEM_ID, + DEFAULT_CONF_DEFAULT_REVERSE, + DEFAULT_PORT, + MYLINK_ENTITY_IDS, +) +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +ENTITY_CONFIG_VERSION = "entity_config_version" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_SYSTEM_ID): int, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + somfy_mylink = SomfyMyLinkSynergy( + data[CONF_SYSTEM_ID], data[CONF_HOST], data[CONF_PORT] + ) + + try: + status_info = await somfy_mylink.status_info() + except asyncio.TimeoutError as ex: + raise CannotConnect from ex + + if not status_info or "error" in status_info: + raise InvalidAuth + + return {"title": f"MyLink {data[CONF_HOST]}"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Somfy MyLink.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_ASSUMED + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + if self._host_already_configured(user_input[CONF_HOST]): + return self.async_abort(reason="already_configured") + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + if self._host_already_configured(user_input[CONF_HOST]): + return self.async_abort(reason="already_configured") + + return await self.async_step_user(user_input) + + def _host_already_configured(self, host): + """See if we already have an entry matching the host.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == host: + return True + return False + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for somfy_mylink.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + self.options = config_entry.options.copy() + self._entity_id = None + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + + if self.config_entry.state != config_entries.ENTRY_STATE_LOADED: + _LOGGER.error("MyLink must be connected to manage device options") + return self.async_abort(reason="cannot_connect") + + if user_input is not None: + self.options[CONF_DEFAULT_REVERSE] = user_input[CONF_DEFAULT_REVERSE] + + entity_id = user_input.get(CONF_ENTITY_ID) + if entity_id: + return await self.async_step_entity_config(None, entity_id) + + return self.async_create_entry(title="", data=self.options) + + data_schema = vol.Schema( + { + vol.Required( + CONF_DEFAULT_REVERSE, + default=self.options.get( + CONF_DEFAULT_REVERSE, DEFAULT_CONF_DEFAULT_REVERSE + ), + ): bool + } + ) + data = self.hass.data[DOMAIN][self.config_entry.entry_id] + mylink_entity_ids = data[MYLINK_ENTITY_IDS] + + if mylink_entity_ids: + entity_dict = {None: None} + for entity_id in mylink_entity_ids: + name = entity_id + state = self.hass.states.get(entity_id) + if state: + name = state.attributes.get(ATTR_FRIENDLY_NAME, entity_id) + entity_dict[entity_id] = f"{name} ({entity_id})" + data_schema = data_schema.extend( + {vol.Optional(CONF_ENTITY_ID): vol.In(entity_dict)} + ) + + return self.async_show_form(step_id="init", data_schema=data_schema, errors={}) + + async def async_step_entity_config(self, user_input=None, entity_id=None): + """Handle options flow for entity.""" + entities_config = self.options.setdefault(CONF_ENTITY_CONFIG, {}) + + if user_input is not None: + entity_config = entities_config.setdefault(self._entity_id, {}) + if entity_config.get(CONF_REVERSE) != user_input[CONF_REVERSE]: + entity_config[CONF_REVERSE] = user_input[CONF_REVERSE] + # If we do not modify a top level key + # the entity config will never be written + self.options.setdefault(ENTITY_CONFIG_VERSION, 0) + self.options[ENTITY_CONFIG_VERSION] += 1 + return await self.async_step_init() + + self._entity_id = entity_id + default_reverse = self.options.get(CONF_DEFAULT_REVERSE, False) + entity_config = entities_config.get(entity_id, {}) + + return self.async_show_form( + step_id="entity_config", + data_schema=vol.Schema( + { + vol.Optional( + CONF_REVERSE, + default=entity_config.get(CONF_REVERSE, default_reverse), + ): bool + } + ), + description_placeholders={ + CONF_ENTITY_ID: entity_id, + }, + errors={}, + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/somfy_mylink/const.py b/homeassistant/components/somfy_mylink/const.py new file mode 100644 index 00000000000..fd4afb67e00 --- /dev/null +++ b/homeassistant/components/somfy_mylink/const.py @@ -0,0 +1,14 @@ +"""Component for the Somfy MyLink device supporting the Synergy API.""" + +CONF_ENTITY_CONFIG = "entity_config" +CONF_SYSTEM_ID = "system_id" +CONF_REVERSE = "reverse" +CONF_DEFAULT_REVERSE = "default_reverse" +DEFAULT_CONF_DEFAULT_REVERSE = False +DATA_SOMFY_MYLINK = "somfy_mylink_data" +MYLINK_STATUS = "mylink_status" +MYLINK_ENTITY_IDS = "mylink_entity_ids" +DOMAIN = "somfy_mylink" +SOMFY_MYLINK_COMPONENTS = ["cover"] +MANUFACTURER = "Somfy" +DEFAULT_PORT = 44100 diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index ac3bf0673f1..eee1ccf3b6f 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -2,49 +2,66 @@ import logging from homeassistant.components.cover import ( + DEVICE_CLASS_BLIND, + DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ENTITY_ID_FORMAT, CoverEntity, ) +from homeassistant.const import STATE_CLOSED, STATE_OPEN +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import slugify -from . import CONF_DEFAULT_REVERSE, DATA_SOMFY_MYLINK +from .const import ( + CONF_DEFAULT_REVERSE, + CONF_REVERSE, + DATA_SOMFY_MYLINK, + DOMAIN, + MANUFACTURER, + MYLINK_ENTITY_IDS, + MYLINK_STATUS, +) _LOGGER = logging.getLogger(__name__) +MYLINK_COVER_TYPE_TO_DEVICE_CLASS = {0: DEVICE_CLASS_BLIND, 1: DEVICE_CLASS_SHUTTER} -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + +async def async_setup_entry(hass, config_entry, async_add_entities): """Discover and configure Somfy covers.""" - if discovery_info is None: - return - somfy_mylink = hass.data[DATA_SOMFY_MYLINK] + data = hass.data[DOMAIN][config_entry.entry_id] + mylink_status = data[MYLINK_STATUS] + somfy_mylink = data[DATA_SOMFY_MYLINK] + mylink_entity_ids = data[MYLINK_ENTITY_IDS] cover_list = [] - try: - mylink_status = await somfy_mylink.status_info() - except TimeoutError: - _LOGGER.error( - "Unable to connect to the Somfy MyLink device, " - "please check your settings" - ) - return + for cover in mylink_status["result"]: entity_id = ENTITY_ID_FORMAT.format(slugify(cover["name"])) - entity_config = discovery_info.get(entity_id, {}) - default_reverse = discovery_info[CONF_DEFAULT_REVERSE] + mylink_entity_ids.append(entity_id) + + entity_config = config_entry.options.get(entity_id, {}) + default_reverse = config_entry.options.get(CONF_DEFAULT_REVERSE) + cover_config = {} cover_config["target_id"] = cover["targetID"] cover_config["name"] = cover["name"] - cover_config["reverse"] = entity_config.get("reverse", default_reverse) + cover_config["device_class"] = MYLINK_COVER_TYPE_TO_DEVICE_CLASS.get( + cover.get("type"), DEVICE_CLASS_WINDOW + ) + cover_config["reverse"] = entity_config.get(CONF_REVERSE, default_reverse) + cover_list.append(SomfyShade(somfy_mylink, **cover_config)) + _LOGGER.info( "Adding Somfy Cover: %s with targetID %s", cover_config["name"], cover_config["target_id"], ) + async_add_entities(cover_list) -class SomfyShade(CoverEntity): +class SomfyShade(RestoreEntity, CoverEntity): """Object for controlling a Somfy cover.""" def __init__( @@ -60,8 +77,16 @@ class SomfyShade(CoverEntity): self._target_id = target_id self._name = name self._reverse = reverse + self._closed = None + self._is_opening = None + self._is_closing = None self._device_class = device_class + @property + def should_poll(self): + """No polling since assumed state.""" + return False + @property def unique_id(self): """Return the unique ID of this cover.""" @@ -72,11 +97,6 @@ class SomfyShade(CoverEntity): """Return the name of the cover.""" return self._name - @property - def is_closed(self): - """Return if the cover is closed.""" - return None - @property def assumed_state(self): """Let HA know the integration is assumed state.""" @@ -87,20 +107,72 @@ class SomfyShade(CoverEntity): """Return the class of this device, from component DEVICE_CLASSES.""" return self._device_class - async def async_open_cover(self, **kwargs): - """Wrap Homeassistant calls to open the cover.""" - if not self._reverse: - await self.somfy_mylink.move_up(self._target_id) - else: - await self.somfy_mylink.move_down(self._target_id) + @property + def is_opening(self): + """Return if the cover is opening.""" + return self._is_opening + + @property + def is_closing(self): + """Return if the cover is closing.""" + return self._is_closing + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return self._closed + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._target_id)}, + "name": self._name, + "manufacturer": MANUFACTURER, + } async def async_close_cover(self, **kwargs): - """Wrap Homeassistant calls to close the cover.""" - if not self._reverse: - await self.somfy_mylink.move_down(self._target_id) - else: - await self.somfy_mylink.move_up(self._target_id) + """Close the cover.""" + self._is_closing = True + self.async_write_ha_state() + try: + # Blocks until the close command is sent + if not self._reverse: + await self.somfy_mylink.move_down(self._target_id) + else: + await self.somfy_mylink.move_up(self._target_id) + self._closed = True + finally: + self._is_closing = None + self.async_write_ha_state() + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + self._is_opening = True + self.async_write_ha_state() + try: + # Blocks until the open command is sent + if not self._reverse: + await self.somfy_mylink.move_up(self._target_id) + else: + await self.somfy_mylink.move_down(self._target_id) + self._closed = False + finally: + self._is_opening = None + self.async_write_ha_state() async def async_stop_cover(self, **kwargs): """Stop the cover.""" await self.somfy_mylink.move_stop(self._target_id) + + async def async_added_to_hass(self): + """Complete the initialization.""" + await super().async_added_to_hass() + # Restore the last state + last_state = await self.async_get_last_state() + + if last_state is not None and last_state.state in ( + STATE_OPEN, + STATE_CLOSED, + ): + self._closed = last_state.state == STATE_CLOSED diff --git a/homeassistant/components/somfy_mylink/manifest.json b/homeassistant/components/somfy_mylink/manifest.json index c259f827d51..e9b4601dee3 100644 --- a/homeassistant/components/somfy_mylink/manifest.json +++ b/homeassistant/components/somfy_mylink/manifest.json @@ -2,6 +2,9 @@ "domain": "somfy_mylink", "name": "Somfy MyLink", "documentation": "https://www.home-assistant.io/integrations/somfy_mylink", - "requirements": ["somfy-mylink-synergy==1.0.6"], - "codeowners": [] -} + "requirements": [ + "somfy-mylink-synergy==1.0.6" + ], + "codeowners": ["@bdraco"], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json new file mode 100644 index 00000000000..bd63fa93d86 --- /dev/null +++ b/homeassistant/components/somfy_mylink/strings.json @@ -0,0 +1,44 @@ +{ + "title": "Somfy MyLink", + "config": { + "step": { + "user": { + "description": "The System ID can be obtained in the MyLink app under Integration by selecting any non-Cloud service.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "system_id": "System ID" + } + } + }, + "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%]" + } + }, + "options": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "init": { + "title": "Configure MyLink Entities", + "data": { + "default_reverse": "Default reversal status for unconfigured covers", + "entity_id": "Configure a specific entity." + } + }, + "entity_config": { + "title": "Configure Entity", + "description": "Configure options for `{entity_id}`", + "data": { + "reverse": "Cover is reversed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/translations/en.json b/homeassistant/components/somfy_mylink/translations/en.json new file mode 100644 index 00000000000..bd63fa93d86 --- /dev/null +++ b/homeassistant/components/somfy_mylink/translations/en.json @@ -0,0 +1,44 @@ +{ + "title": "Somfy MyLink", + "config": { + "step": { + "user": { + "description": "The System ID can be obtained in the MyLink app under Integration by selecting any non-Cloud service.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "system_id": "System ID" + } + } + }, + "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%]" + } + }, + "options": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "init": { + "title": "Configure MyLink Entities", + "data": { + "default_reverse": "Default reversal status for unconfigured covers", + "entity_id": "Configure a specific entity." + } + }, + "entity_config": { + "title": "Configure Entity", + "description": "Configure options for `{entity_id}`", + "data": { + "reverse": "Cover is reversed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 43e0647a258..b696e29964a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -190,6 +190,7 @@ FLOWS = [ "solarlog", "soma", "somfy", + "somfy_mylink", "sonarr", "songpal", "sonos", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a150815bbd..6dc4f724a7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,6 +1014,9 @@ solaredge==0.0.2 # homeassistant.components.honeywell somecomfort==0.5.2 +# homeassistant.components.somfy_mylink +somfy-mylink-synergy==1.0.6 + # homeassistant.components.sonarr sonarr==0.3.0 diff --git a/tests/components/somfy_mylink/__init__.py b/tests/components/somfy_mylink/__init__.py new file mode 100644 index 00000000000..b1141243997 --- /dev/null +++ b/tests/components/somfy_mylink/__init__.py @@ -0,0 +1 @@ +"""Tests for the Somfy MyLink integration.""" diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py new file mode 100644 index 00000000000..1a81e28a7c6 --- /dev/null +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -0,0 +1,385 @@ +"""Test the Somfy MyLink config flow.""" +import asyncio +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.somfy_mylink.const import ( + CONF_DEFAULT_REVERSE, + CONF_ENTITY_CONFIG, + CONF_REVERSE, + CONF_SYSTEM_ID, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_form_user(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( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + return_value={"any": "data"}, + ), patch( + "homeassistant.components.somfy_mylink.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.somfy_mylink.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "MyLink 1.1.1.1" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_already_configured(hass): + """Test we abort if already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 12, CONF_SYSTEM_ID: 46}, + ) + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + return_value={"any": "data"}, + ), patch( + "homeassistant.components.somfy_mylink.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.somfy_mylink.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_form_import(hass): + """Test we get the form with import source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + return_value={"any": "data"}, + ), patch( + "homeassistant.components.somfy_mylink.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.somfy_mylink.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={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "MyLink 1.1.1.1" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_import_with_entity_config(hass): + """Test we can import entity config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + return_value={"any": "data"}, + ), patch( + "homeassistant.components.somfy_mylink.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.somfy_mylink.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={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + CONF_DEFAULT_REVERSE: True, + CONF_ENTITY_CONFIG: {"cover.xyz": {CONF_REVERSE: False}}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "MyLink 1.1.1.1" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + CONF_DEFAULT_REVERSE: True, + CONF_ENTITY_CONFIG: {"cover.xyz": {CONF_REVERSE: False}}, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_import_already_exists(hass): + """Test we get the form with import source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 12, CONF_SYSTEM_ID: 46}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + return_value={"any": "data"}, + ), patch( + "homeassistant.components.somfy_mylink.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.somfy_mylink.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={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +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( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + return_value={ + "jsonrpc": "2.0", + "error": {"code": -32652, "message": "Invalid auth"}, + "id": 818, + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle broad exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.somfy_mylink.config_flow.SomfyMyLinkSynergy.status_info", + side_effect=ValueError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 1234, + CONF_SYSTEM_ID: 456, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_options_not_loaded(hass): + """Test options will not display until loaded.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 12, CONF_SYSTEM_ID: 46}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.somfy_mylink.SomfyMyLinkSynergy.status_info", + return_value={"result": []}, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_options_no_entities(hass): + """Test we can configure default reverse.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 12, CONF_SYSTEM_ID: 46}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.somfy_mylink.SomfyMyLinkSynergy.status_info", + return_value={"result": []}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"default_reverse": True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "default_reverse": True, + } + + await hass.async_block_till_done() + + +async def test_options_with_entities(hass): + """Test we can configure reverse for an entity.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 12, CONF_SYSTEM_ID: 46}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.somfy_mylink.SomfyMyLinkSynergy.status_info", + return_value={ + "result": [ + { + "targetID": "a", + "name": "Master Window", + "type": 0, + } + ] + }, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"default_reverse": True, "entity_id": "cover.master_window"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"reverse": False}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={"default_reverse": True, "entity_id": None}, + ) + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + assert config_entry.options == { + "default_reverse": True, + "entity_config": {"cover.master_window": {"reverse": False}}, + "entity_config_version": 1, + } + + await hass.async_block_till_done()