Add config flow for cover, fan, light and media_player groups (#67660)

* Add options flow support to HelperConfigFlowHandler

* Add config flow for cover, fan, light and media_player groups

* Update according to review comments

* Update translation strings

* Update translation strings

* Copy schema before adding suggested values
pull/67799/head
Erik Montnemery 2022-03-07 13:05:04 +01:00 committed by GitHub
parent 6a92081e83
commit a9cc2d2322
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 546 additions and 16 deletions

View File

@ -11,6 +11,7 @@ from typing import Any, Union, cast
import voluptuous as vol
from homeassistant import core as ha
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
@ -58,6 +59,14 @@ ATTR_ALL = "all"
SERVICE_SET = "set"
SERVICE_REMOVE = "remove"
PLATFORMS_CONFIG_ENTRY = [
Platform.BINARY_SENSOR,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.MEDIA_PLAYER,
]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.COVER,
@ -218,6 +227,25 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]:
return groups
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
hass.config_entries.async_setup_platforms(entry, (entry.options["group_type"],))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
entry, (entry.options["group_type"],)
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up all groups found defined in the configuration."""
if DOMAIN not in hass.data:

View File

@ -0,0 +1,81 @@
"""Config flow for Group integration."""
from __future__ import annotations
from typing import Any, cast
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITIES
from homeassistant.helpers import helper_config_entry_flow, selector
from . import DOMAIN
def basic_group_options_schema(domain: str) -> vol.Schema:
"""Generate options schema."""
return vol.Schema(
{
vol.Required(CONF_ENTITIES): selector.selector(
{"entity": {"domain": domain, "multiple": True}}
),
}
)
def basic_group_config_schema(domain: str) -> vol.Schema:
"""Generate config schema."""
return vol.Schema({vol.Required("name"): selector.selector({"text": {}})}).extend(
basic_group_options_schema(domain).schema
)
STEPS = {
"init": vol.Schema(
{
vol.Required("group_type"): selector.selector(
{
"select": {
"options": [
"cover",
"fan",
"light",
"media_player",
]
}
}
)
}
),
"cover": basic_group_config_schema("cover"),
"fan": basic_group_config_schema("fan"),
"light": basic_group_config_schema("light"),
"media_player": basic_group_config_schema("media_player"),
"cover_options": basic_group_options_schema("cover"),
"fan_options": basic_group_options_schema("fan"),
"light_options": basic_group_options_schema("light"),
"media_player_options": basic_group_options_schema("media_player"),
}
class GroupConfigFlowHandler(
helper_config_entry_flow.HelperConfigFlowHandler, domain=DOMAIN
):
"""Handle a config or options flow for Switch Light."""
steps = STEPS
def async_config_entry_title(self, user_input: dict[str, Any]) -> str:
"""Return config entry title."""
return cast(str, user_input["name"]) if "name" in user_input else ""
@staticmethod
def async_initial_options_step(config_entry: ConfigEntry) -> str:
"""Return initial options step."""
return f"{config_entry.options['group_type']}_options"
def async_next_step(self, step_id: str, user_input: dict[str, Any]) -> str | None:
"""Return next step_id."""
if step_id == "init":
return cast(str, user_input["group_type"])
return None

View File

@ -22,6 +22,7 @@ from homeassistant.components.cover import (
SUPPORT_STOP_TILT,
CoverEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
@ -43,7 +44,7 @@ from homeassistant.const import (
STATE_OPENING,
)
from homeassistant.core import Event, HomeAssistant, State, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -85,6 +86,22 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize Light Switch config entry."""
registry = er.async_get(hass)
entity_id = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[CoverGroup(config_entry.entry_id, config_entry.title, entity_id)]
)
class CoverGroup(GroupEntity, CoverEntity):
"""Representation of a CoverGroup."""

View File

@ -25,6 +25,7 @@ from homeassistant.components.fan import (
SUPPORT_SET_SPEED,
FanEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
@ -35,7 +36,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import Event, HomeAssistant, State, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -78,6 +79,20 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize Light Switch config entry."""
registry = er.async_get(hass)
entity_id = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entity_id)])
class FanGroup(GroupEntity, FanEntity):
"""Representation of a FanGroup."""

View File

@ -36,6 +36,7 @@ from homeassistant.components.light import (
SUPPORT_WHITE_VALUE,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
@ -48,7 +49,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
)
from homeassistant.core import Event, HomeAssistant, State, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -92,6 +93,22 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize Light Switch config entry."""
registry = er.async_get(hass)
entity_id = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[LightGroup(config_entry.entry_id, config_entry.title, entity_id)]
)
FORWARDED_ATTRIBUTES = frozenset(
{
ATTR_BRIGHTNESS,

View File

@ -2,7 +2,10 @@
"domain": "group",
"name": "Group",
"documentation": "https://www.home-assistant.io/integrations/group",
"codeowners": ["@home-assistant/core"],
"codeowners": [
"@home-assistant/core"
],
"quality_scale": "internal",
"iot_class": "calculated"
"iot_class": "calculated",
"config_flow": true
}

View File

@ -32,6 +32,7 @@ from homeassistant.components.media_player import (
SUPPORT_VOLUME_STEP,
MediaPlayerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
@ -55,7 +56,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
@ -96,6 +97,22 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize Light Switch config entry."""
registry = er.async_get(hass)
entity_id = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[MediaGroup(config_entry.entry_id, config_entry.title, entity_id)]
)
class MediaGroup(MediaPlayerEntity):
"""Representation of a Media Group."""

View File

@ -1,5 +1,67 @@
{
"title": "Group",
"config": {
"step": {
"init": {
"description": "Select group type",
"data": {
"group_type": "Group type"
}
},
"cover": {
"description": "Select group options",
"data": {
"entities": "Group members",
"name": "Group name"
}
},
"cover_options": {
"description": "Select group options",
"data": {
"entities": "Group members"
}
},
"fan": {
"description": "Select group options",
"data": {
"entities": "Group members",
"name": "Group name"
}
},
"fan_options": {
"description": "Select group options",
"data": {
"entities": "Group members"
}
},
"light": {
"description": "Select group options",
"data": {
"entities": "Group members",
"name": "Group name"
}
},
"light_options": {
"description": "Select group options",
"data": {
"entities": "Group members"
}
},
"media_player": {
"description": "Select group options",
"data": {
"entities": "Group members",
"name": "Group name"
}
},
"media_player_options": {
"description": "Select group options",
"data": {
"entities": "Group members"
}
}
}
},
"state": {
"_": {
"off": "[%key:common::state::off%]",

View File

@ -127,6 +127,7 @@ FLOWS = [
"google_travel_time",
"gpslogger",
"gree",
"group",
"growatt_server",
"guardian",
"habitica",

View File

@ -2,13 +2,20 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Awaitable, Callable
import copy
import types
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, FlowResult
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import (
RESULT_TYPE_CREATE_ENTRY,
FlowResult,
UnknownHandler,
)
class HelperCommonFlowHandler:
@ -16,19 +23,18 @@ class HelperCommonFlowHandler:
def __init__(
self,
handler: HelperConfigFlowHandler,
handler: HelperConfigFlowHandler | HelperOptionsFlowHandler,
config_entry: config_entries.ConfigEntry | None,
) -> None:
"""Initialize a common handler."""
self._handler = handler
self._options = dict(config_entry.options) if config_entry is not None else {}
async def async_step(self, _user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step(
self, step_id: str, _user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a step."""
errors = None
step_id = (
self._handler.cur_step["step_id"] if self._handler.cur_step else "init"
)
if _user_input is not None:
errors = {}
try:
@ -38,19 +44,28 @@ class HelperCommonFlowHandler:
except vol.Invalid as exc:
errors["base"] = str(exc)
else:
self._options.update(user_input)
if (
next_step_id := self._handler.async_next_step(step_id, user_input)
) is None:
title = self._handler.async_config_entry_title(user_input)
return self._handler.async_create_entry(
title=title, data=user_input
title=title, data=self._options
)
return self._handler.async_show_form(
step_id=next_step_id, data_schema=self._handler.steps[next_step_id]
)
schema = dict(self._handler.steps[step_id].schema)
for key in list(schema):
if key in self._options and isinstance(key, vol.Marker):
new_key = copy.copy(key)
new_key.description = {"suggested_value": self._options[key]}
val = schema.pop(key)
schema[new_key] = val
return self._handler.async_show_form(
step_id=step_id, data_schema=self._handler.steps[step_id], errors=errors
step_id=step_id, data_schema=vol.Schema(schema), errors=errors
)
@ -66,6 +81,29 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow):
"""Initialize a subclass, register if possible."""
super().__init_subclass__(**kwargs)
@callback
def _async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
if (
cls.async_initial_options_step
is HelperConfigFlowHandler.async_initial_options_step
):
raise UnknownHandler
return HelperOptionsFlowHandler(
config_entry,
cls.steps,
cls.async_config_entry_title,
cls.async_initial_options_step,
cls.async_next_step,
cls.async_validate_input,
)
# Create an async_get_options_flow method
cls.async_get_options_flow = _async_get_options_flow # type: ignore[assignment]
# Create flow step methods for each step defined in the flow schema
for step in cls.steps:
setattr(cls, f"async_step_{step}", cls.async_step)
@ -73,6 +111,17 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow):
"""Initialize config flow."""
self._common_handler = HelperCommonFlowHandler(self, None)
@classmethod
@callback
def async_supports_options_flow(
cls, config_entry: config_entries.ConfigEntry
) -> bool:
"""Return options flow support for this handler."""
return (
cls.async_initial_options_step
is not HelperConfigFlowHandler.async_initial_options_step
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@ -81,7 +130,8 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow):
async def async_step(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle a step."""
result = await self._common_handler.async_step(user_input)
step_id = self.cur_step["step_id"] if self.cur_step else "init"
result = await self._common_handler.async_step(step_id, user_input)
if result["type"] == RESULT_TYPE_CREATE_ENTRY:
result["options"] = result["data"]
result["data"] = {}
@ -97,9 +147,57 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow):
"""Return next step_id, or None to finish the flow."""
return None
@staticmethod
@callback
def async_initial_options_step(
config_entry: config_entries.ConfigEntry,
) -> str:
"""Return initial step_id of options flow."""
raise UnknownHandler
# pylint: disable-next=no-self-use
async def async_validate_input(
self, hass: HomeAssistant, step_id: str, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate user input."""
return user_input
class HelperOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle an options flow for helper integrations."""
def __init__(
self,
config_entry: config_entries.ConfigEntry,
steps: dict[str, vol.Schema],
config_entry_title: Callable[[Any, dict[str, Any]], str],
initial_step: Callable[[config_entries.ConfigEntry], str],
next_step: Callable[[Any, str, dict[str, Any]], str | None],
validate: Callable[
[Any, HomeAssistant, str, dict[str, Any]], Awaitable[dict[str, Any]]
],
) -> None:
"""Initialize options flow."""
self._common_handler = HelperCommonFlowHandler(self, config_entry)
self._config_entry = config_entry
self._initial_step = initial_step(config_entry)
self.async_config_entry_title = types.MethodType(config_entry_title, self)
self.async_next_step = types.MethodType(next_step, self)
self.async_validate_input = types.MethodType(validate, self)
self.steps = steps
for step in self.steps:
if step == "init":
continue
setattr(self, f"async_step_{step}", self.async_step)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
return await self.async_step(user_input)
async def async_step(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle a step."""
# pylint: disable-next=unsubscriptable-object # self.cur_step is a dict
step_id = self.cur_step["step_id"] if self.cur_step else self._initial_step
return await self._common_handler.async_step(step_id, user_input)

View File

@ -0,0 +1,191 @@
"""Test the Switch config flow."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.group import DOMAIN, async_setup_entry
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
@pytest.mark.parametrize(
"group_type,group_state,member_state,member_attributes",
(
("cover", "open", "open", {}),
("fan", "on", "on", {}),
("light", "on", "on", {}),
("media_player", "on", "on", {}),
),
)
async def test_config_flow(
hass: HomeAssistant, group_type, group_state, member_state, member_attributes
) -> None:
"""Test the config flow."""
members = [f"{group_type}.one", f"{group_type}.two"]
for member in members:
hass.states.async_set(member, member_state, member_attributes)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"group_type": group_type},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == group_type
with patch(
"homeassistant.components.group.async_setup_entry", wraps=async_setup_entry
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"name": "Living Room",
"entities": members,
},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Living Room"
assert result["data"] == {}
assert result["options"] == {
"group_type": group_type,
"entities": members,
"name": "Living Room",
}
assert len(mock_setup_entry.mock_calls) == 1
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {}
assert config_entry.options == {
"group_type": group_type,
"name": "Living Room",
"entities": members,
}
state = hass.states.get(f"{group_type}.living_room")
assert state.state == group_state
assert state.attributes["entity_id"] == members
def get_suggested(schema, key):
"""Get suggested value for key in voluptuous schema."""
for k in schema.keys():
if k == key:
if k.description is None or "suggested_value" not in k.description:
return None
return k.description["suggested_value"]
# Wanted key absent from schema
raise Exception
@pytest.mark.parametrize(
"group_type,member_state",
(("cover", "open"), ("fan", "on"), ("light", "on"), ("media_player", "on")),
)
async def test_options(hass: HomeAssistant, group_type, member_state) -> None:
"""Test reconfiguring."""
members1 = [f"{group_type}.one", f"{group_type}.two"]
members2 = [f"{group_type}.four", f"{group_type}.five"]
for member in members1:
hass.states.async_set(member, member_state, {})
for member in members2:
hass.states.async_set(member, member_state, {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
assert get_suggested(result["data_schema"].schema, "group_type") is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"group_type": group_type},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == group_type
assert get_suggested(result["data_schema"].schema, "entities") is None
assert get_suggested(result["data_schema"].schema, "name") is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"name": "Bed Room",
"entities": members1,
},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
state = hass.states.get(f"{group_type}.bed_room")
assert state.attributes["entity_id"] == members1
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {}
assert config_entry.options == {
"group_type": group_type,
"entities": members1,
"name": "Bed Room",
}
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == f"{group_type}_options"
assert get_suggested(result["data_schema"].schema, "entities") == members1
assert "name" not in result["data_schema"].schema
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entities": members2,
},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
"group_type": group_type,
"entities": members2,
"name": "Bed Room",
}
assert config_entry.data == {}
assert config_entry.options == {
"group_type": group_type,
"entities": members2,
"name": "Bed Room",
}
assert config_entry.title == "Bed Room"
# Check config entry is reloaded with new options
await hass.async_block_till_done()
state = hass.states.get(f"{group_type}.bed_room")
assert state.attributes["entity_id"] == members2
# Check we don't get suggestions from another entry
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
assert get_suggested(result["data_schema"].schema, "group_type") is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"group_type": group_type},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == group_type
assert get_suggested(result["data_schema"].schema, "entities") is None
assert get_suggested(result["data_schema"].schema, "name") is None