Cleanups for somfy_mylink (#45026)

* Cleanups for somfy_mylink

* Use the target/unique_id to configure reverse

* Simplify options flow

* Various code review cleanups

* Deprecate YAML

* revert get change

* revert get change

* add note about empty response

* move CONF_DEFAULT_REVERSE out of loop

* Update homeassistant/components/somfy_mylink/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Ensure we deepcopy options

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2021-01-11 10:10:02 -10:00 committed by GitHub
parent f19b72ea02
commit 13cdf0ba63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 205 additions and 144 deletions

View File

@ -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(

View File

@ -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={},
)

View File

@ -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

View File

@ -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))

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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()