diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 5427dee5916..d15ea029530 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -5,21 +5,23 @@ import logging from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol +from homeassistant.components.cover import ENTITY_ID_FORMAT 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.util import slugify from .const import ( CONF_DEFAULT_REVERSE, CONF_ENTITY_CONFIG, CONF_REVERSE, + CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DEFAULT_PORT, DOMAIN, - MYLINK_ENTITY_IDS, MYLINK_STATUS, SOMFY_MYLINK_COMPONENTS, ) @@ -44,17 +46,22 @@ def validate_entity_config(values): CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_SYSTEM_ID): cv.string, - vol.Required(CONF_HOST): cv.string, - 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, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_SYSTEM_ID): cv.string, + vol.Required(CONF_HOST): cv.string, + 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, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -92,19 +99,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): "Unable to connect to the Somfy MyLink device, please check your settings" ) from ex - if "error" in mylink_status: + if not mylink_status or "error" in mylink_status: _LOGGER.error( "mylink failed to setup because of an error: %s", - mylink_status.get("error", {}).get("message"), + mylink_status.get("error", {}).get( + "message", "Empty response from mylink device" + ), ) return False + _async_migrate_entity_config(hass, entry, mylink_status) + 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, } @@ -136,6 +146,34 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi hass.config_entries.async_update_entry(entry, data=data, options=options) +@callback +def _async_migrate_entity_config( + hass: HomeAssistant, entry: ConfigEntry, mylink_status: dict +): + if CONF_ENTITY_CONFIG not in entry.options: + return + + options = dict(entry.options) + + reversed_target_ids = options[CONF_REVERSED_TARGET_IDS] = {} + legacy_entry_config = options[CONF_ENTITY_CONFIG] + default_reverse = options.get(CONF_DEFAULT_REVERSE) + + for cover in mylink_status["result"]: + legacy_entity_id = ENTITY_ID_FORMAT.format(slugify(cover["name"])) + target_id = cover["targetID"] + + entity_config = legacy_entry_config.get(legacy_entity_id, {}) + if entity_config.get(CONF_REVERSE, default_reverse): + reversed_target_ids[target_id] = True + + for legacy_key in (CONF_DEFAULT_REVERSE, CONF_ENTITY_CONFIG): + if legacy_key in options: + del options[legacy_key] + + hass.config_entries.async_update_entry(entry, data=entry.data, options=options) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 6f66c9899b4..4a955c53df5 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -1,29 +1,28 @@ """Config flow for Somfy MyLink integration.""" import asyncio +from copy import deepcopy 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.const import CONF_HOST, CONF_PORT from homeassistant.core import callback from .const import ( - CONF_DEFAULT_REVERSE, - CONF_ENTITY_CONFIG, CONF_REVERSE, + CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, - DEFAULT_CONF_DEFAULT_REVERSE, + CONF_TARGET_ID, + CONF_TARGET_NAME, DEFAULT_PORT, - MYLINK_ENTITY_IDS, + MYLINK_STATUS, ) 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, @@ -114,8 +113,24 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 + self.options = deepcopy(dict(config_entry.options)) + self._target_id = None + + @callback + def _async_callback_targets(self): + """Return the list of targets.""" + return self.hass.data[DOMAIN][self.config_entry.entry_id][MYLINK_STATUS][ + "result" + ] + + @callback + def _async_get_target_name(self, target_id) -> str: + """Find the name of a target in the api data.""" + mylink_targets = self._async_callback_targets() + for cover in mylink_targets: + if cover["targetID"] == target_id: + return cover["name"] + raise KeyError async def async_step_init(self, user_input=None): """Handle options flow.""" @@ -125,71 +140,45 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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) + target_id = user_input.get(CONF_TARGET_ID) + if target_id: + return await self.async_step_target_config(None, target_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] + cover_dict = {None: None} + mylink_targets = self._async_callback_targets() + if mylink_targets: + for cover in mylink_targets: + cover_dict[cover["targetID"]] = cover["name"] - 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)} - ) + data_schema = vol.Schema({vol.Optional(CONF_TARGET_ID): vol.In(cover_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, {}) + async def async_step_target_config(self, user_input=None, target_id=None): + """Handle options flow for target.""" + reversed_target_ids = self.options.setdefault(CONF_REVERSED_TARGET_IDS, {}) 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 + if user_input[CONF_REVERSE] != reversed_target_ids.get(self._target_id): + reversed_target_ids[self._target_id] = user_input[CONF_REVERSE] 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, {}) + self._target_id = target_id return self.async_show_form( - step_id="entity_config", + step_id="target_config", data_schema=vol.Schema( { vol.Optional( CONF_REVERSE, - default=entity_config.get(CONF_REVERSE, default_reverse), + default=reversed_target_ids.get(target_id, False), ): bool } ), description_placeholders={ - CONF_ENTITY_ID: entity_id, + CONF_TARGET_NAME: self._async_get_target_name(target_id), }, errors={}, ) diff --git a/homeassistant/components/somfy_mylink/const.py b/homeassistant/components/somfy_mylink/const.py index fd4afb67e00..a7cbf864cd9 100644 --- a/homeassistant/components/somfy_mylink/const.py +++ b/homeassistant/components/somfy_mylink/const.py @@ -4,11 +4,16 @@ CONF_ENTITY_CONFIG = "entity_config" CONF_SYSTEM_ID = "system_id" CONF_REVERSE = "reverse" CONF_DEFAULT_REVERSE = "default_reverse" -DEFAULT_CONF_DEFAULT_REVERSE = False +CONF_TARGET_NAME = "target_name" +CONF_REVERSED_TARGET_IDS = "reversed_target_ids" +CONF_TARGET_ID = "target_id" + +DEFAULT_PORT = 44100 + 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 eee1ccf3b6f..2725e2da9c7 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -5,20 +5,16 @@ 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 .const import ( - CONF_DEFAULT_REVERSE, - CONF_REVERSE, + CONF_REVERSED_TARGET_IDS, DATA_SOMFY_MYLINK, DOMAIN, MANUFACTURER, - MYLINK_ENTITY_IDS, MYLINK_STATUS, ) @@ -29,26 +25,22 @@ MYLINK_COVER_TYPE_TO_DEVICE_CLASS = {0: DEVICE_CLASS_BLIND, 1: DEVICE_CLASS_SHUT async def async_setup_entry(hass, config_entry, async_add_entities): """Discover and configure Somfy covers.""" + reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {}) + 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 = [] for cover in mylink_status["result"]: - entity_id = ENTITY_ID_FORMAT.format(slugify(cover["name"])) - 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["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_config = { + "target_id": cover["targetID"], + "name": cover["name"], + "device_class": MYLINK_COVER_TYPE_TO_DEVICE_CLASS.get( + cover.get("type"), DEVICE_CLASS_WINDOW + ), + "reverse": reversed_target_ids.get(cover["targetID"], False), + } cover_list.append(SomfyShade(somfy_mylink, **cover_config)) diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index bd63fa93d86..f6eed3457d2 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -26,15 +26,14 @@ }, "step": { "init": { - "title": "Configure MyLink Entities", + "title": "Configure MyLink Options", "data": { - "default_reverse": "Default reversal status for unconfigured covers", - "entity_id": "Configure a specific entity." + "target_id": "Configure options for a cover." } }, - "entity_config": { - "title": "Configure Entity", - "description": "Configure options for `{entity_id}`", + "target_config": { + "title": "Configure MyLink Cover", + "description": "Configure options for `{target_name}`", "data": { "reverse": "Cover is reversed" } diff --git a/homeassistant/components/somfy_mylink/translations/en.json b/homeassistant/components/somfy_mylink/translations/en.json index bd63fa93d86..f6eed3457d2 100644 --- a/homeassistant/components/somfy_mylink/translations/en.json +++ b/homeassistant/components/somfy_mylink/translations/en.json @@ -26,15 +26,14 @@ }, "step": { "init": { - "title": "Configure MyLink Entities", + "title": "Configure MyLink Options", "data": { - "default_reverse": "Default reversal status for unconfigured covers", - "entity_id": "Configure a specific entity." + "target_id": "Configure options for a cover." } }, - "entity_config": { - "title": "Configure Entity", - "description": "Configure options for `{entity_id}`", + "target_config": { + "title": "Configure MyLink Cover", + "description": "Configure options for `{target_name}`", "data": { "reverse": "Cover is reversed" } diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py index 1a81e28a7c6..6445e5a2535 100644 --- a/tests/components/somfy_mylink/test_config_flow.py +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -2,11 +2,14 @@ import asyncio from unittest.mock import patch +import pytest + 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_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, DOMAIN, ) @@ -294,42 +297,9 @@ async def test_options_not_loaded(hass): 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.""" +@pytest.mark.parametrize("reversed", [True, False]) +async def test_options_with_targets(hass, reversed): + """Test we can configure reverse for a target.""" await setup.async_setup_component(hass, "persistent_notification", {}) config_entry = MockConfigEntry( @@ -359,27 +329,96 @@ async def test_options_with_entities(hass): result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"default_reverse": True, "entity_id": "cover.master_window"}, + user_input={"target_id": "a"}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM result3 = await hass.config_entries.options.async_configure( result2["flow_id"], - user_input={"reverse": False}, + user_input={"reverse": reversed}, ) 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}, + user_input={"target_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, + CONF_REVERSED_TARGET_IDS: {"a": reversed}, + } + + await hass.async_block_till_done() + + +@pytest.mark.parametrize("reversed", [True, False]) +async def test_form_import_with_entity_config_modify_options(hass, reversed): + """Test we can import entity config and modify options.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mock_imported_config_entry = MockConfigEntry( + domain=DOMAIN, + 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}}, + }, + ) + mock_imported_config_entry.add_to_hass(hass) + + mock_status_info = { + "result": [ + {"targetID": "1.1", "name": "xyz"}, + {"targetID": "1.2", "name": "zulu"}, + ] + } + + with patch( + "homeassistant.components.somfy_mylink.SomfyMyLinkSynergy.status_info", + return_value=mock_status_info, + ): + assert await hass.config_entries.async_setup( + mock_imported_config_entry.entry_id + ) + await hass.async_block_till_done() + + assert mock_imported_config_entry.options == { + "reversed_target_ids": {"1.2": True} + } + + result = await hass.config_entries.options.async_init( + mock_imported_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={"target_id": "1.2"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"reverse": reversed}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={"target_id": None}, + ) + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + # Will not be altered if nothing changes + assert mock_imported_config_entry.options == { + CONF_REVERSED_TARGET_IDS: {"1.2": reversed}, } await hass.async_block_till_done()