mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 17:57:55 +00:00
Add button group support (#121715)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
parent
acb4a92628
commit
f94b28f72d
131
homeassistant/components/group/button.py
Normal file
131
homeassistant/components/group/button.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""Platform allowing several button entities to be grouped into one single button."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.button import (
|
||||||
|
DOMAIN,
|
||||||
|
PLATFORM_SCHEMA as BUTTON_PLATFORM_SCHEMA,
|
||||||
|
SERVICE_PRESS,
|
||||||
|
ButtonEntity,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
|
CONF_ENTITIES,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_UNIQUE_ID,
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
from .entity import GroupEntity
|
||||||
|
|
||||||
|
DEFAULT_NAME = "Button group"
|
||||||
|
|
||||||
|
# No limit on parallel updates to enable a group calling another group
|
||||||
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = BUTTON_PLATFORM_SCHEMA.extend(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN),
|
||||||
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||||
|
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(
|
||||||
|
_: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
__: DiscoveryInfoType | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the button group platform."""
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
ButtonGroup(
|
||||||
|
config.get(CONF_UNIQUE_ID),
|
||||||
|
config[CONF_NAME],
|
||||||
|
config[CONF_ENTITIES],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize button group config entry."""
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
entities = er.async_validate_entity_ids(
|
||||||
|
registry, config_entry.options[CONF_ENTITIES]
|
||||||
|
)
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
ButtonGroup(
|
||||||
|
config_entry.entry_id,
|
||||||
|
config_entry.title,
|
||||||
|
entities,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_create_preview_button(
|
||||||
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
|
) -> ButtonGroup:
|
||||||
|
"""Create a preview button."""
|
||||||
|
return ButtonGroup(
|
||||||
|
None,
|
||||||
|
name,
|
||||||
|
validated_config[CONF_ENTITIES],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonGroup(GroupEntity, ButtonEntity):
|
||||||
|
"""Representation of an button group."""
|
||||||
|
|
||||||
|
_attr_available = False
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
unique_id: str | None,
|
||||||
|
name: str,
|
||||||
|
entity_ids: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a button group."""
|
||||||
|
self._entity_ids = entity_ids
|
||||||
|
self._attr_name = name
|
||||||
|
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
|
||||||
|
self._attr_unique_id = unique_id
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Forward the press to all buttons in the group."""
|
||||||
|
await self.hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_PRESS,
|
||||||
|
{ATTR_ENTITY_ID: self._entity_ids},
|
||||||
|
blocking=True,
|
||||||
|
context=self._context,
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_group_state(self) -> None:
|
||||||
|
"""Query all members and determine the button group state."""
|
||||||
|
# Set group as unavailable if all members are unavailable or missing
|
||||||
|
self._attr_available = any(
|
||||||
|
state.state != STATE_UNAVAILABLE
|
||||||
|
for entity_id in self._entity_ids
|
||||||
|
if (state := self.hass.states.get(entity_id)) is not None
|
||||||
|
)
|
@ -23,6 +23,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor
|
from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor
|
||||||
|
from .button import async_create_preview_button
|
||||||
from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC, DOMAIN
|
from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC, DOMAIN
|
||||||
from .cover import async_create_preview_cover
|
from .cover import async_create_preview_cover
|
||||||
from .entity import GroupEntity
|
from .entity import GroupEntity
|
||||||
@ -146,6 +147,7 @@ async def light_switch_options_schema(
|
|||||||
|
|
||||||
GROUP_TYPES = [
|
GROUP_TYPES = [
|
||||||
"binary_sensor",
|
"binary_sensor",
|
||||||
|
"button",
|
||||||
"cover",
|
"cover",
|
||||||
"event",
|
"event",
|
||||||
"fan",
|
"fan",
|
||||||
@ -185,6 +187,11 @@ CONFIG_FLOW = {
|
|||||||
preview="group",
|
preview="group",
|
||||||
validate_user_input=set_group_type("binary_sensor"),
|
validate_user_input=set_group_type("binary_sensor"),
|
||||||
),
|
),
|
||||||
|
"button": SchemaFlowFormStep(
|
||||||
|
basic_group_config_schema("button"),
|
||||||
|
preview="group",
|
||||||
|
validate_user_input=set_group_type("button"),
|
||||||
|
),
|
||||||
"cover": SchemaFlowFormStep(
|
"cover": SchemaFlowFormStep(
|
||||||
basic_group_config_schema("cover"),
|
basic_group_config_schema("cover"),
|
||||||
preview="group",
|
preview="group",
|
||||||
@ -234,6 +241,10 @@ OPTIONS_FLOW = {
|
|||||||
binary_sensor_options_schema,
|
binary_sensor_options_schema,
|
||||||
preview="group",
|
preview="group",
|
||||||
),
|
),
|
||||||
|
"button": SchemaFlowFormStep(
|
||||||
|
partial(basic_group_options_schema, "button"),
|
||||||
|
preview="group",
|
||||||
|
),
|
||||||
"cover": SchemaFlowFormStep(
|
"cover": SchemaFlowFormStep(
|
||||||
partial(basic_group_options_schema, "cover"),
|
partial(basic_group_options_schema, "cover"),
|
||||||
preview="group",
|
preview="group",
|
||||||
@ -275,6 +286,7 @@ CREATE_PREVIEW_ENTITY: dict[
|
|||||||
Callable[[HomeAssistant, str, dict[str, Any]], GroupEntity | MediaPlayerGroup],
|
Callable[[HomeAssistant, str, dict[str, Any]], GroupEntity | MediaPlayerGroup],
|
||||||
] = {
|
] = {
|
||||||
"binary_sensor": async_create_preview_binary_sensor,
|
"binary_sensor": async_create_preview_binary_sensor,
|
||||||
|
"button": async_create_preview_button,
|
||||||
"cover": async_create_preview_cover,
|
"cover": async_create_preview_cover,
|
||||||
"event": async_create_preview_event,
|
"event": async_create_preview_event,
|
||||||
"fan": async_create_preview_fan,
|
"fan": async_create_preview_fan,
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
|
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"binary_sensor": "Binary sensor group",
|
"binary_sensor": "Binary sensor group",
|
||||||
|
"button": "Button group",
|
||||||
"cover": "Cover group",
|
"cover": "Cover group",
|
||||||
"event": "Event group",
|
"event": "Event group",
|
||||||
"fan": "Fan group",
|
"fan": "Fan group",
|
||||||
@ -27,6 +28,14 @@
|
|||||||
"name": "[%key:common::config_flow::data::name%]"
|
"name": "[%key:common::config_flow::data::name%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"button": {
|
||||||
|
"title": "[%key:component::group::config::step::user::title%]",
|
||||||
|
"data": {
|
||||||
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
|
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
|
||||||
|
"name": "[%key:common::config_flow::data::name%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cover": {
|
"cover": {
|
||||||
"title": "[%key:component::group::config::step::user::title%]",
|
"title": "[%key:component::group::config::step::user::title%]",
|
||||||
"data": {
|
"data": {
|
||||||
@ -109,6 +118,12 @@
|
|||||||
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"button": {
|
||||||
|
"data": {
|
||||||
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
|
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cover": {
|
"cover": {
|
||||||
"data": {
|
"data": {
|
||||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
|
122
tests/components/group/test_button.py
Normal file
122
tests/components/group/test_button.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""The tests for the group button platform."""
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
|
||||||
|
from homeassistant.components.group import DOMAIN
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
|
||||||
|
async def test_default_state(
|
||||||
|
hass: HomeAssistant, entity_registry: er.EntityRegistry
|
||||||
|
) -> None:
|
||||||
|
"""Test button group default state."""
|
||||||
|
hass.states.async_set("button.notify_light", "2021-01-01T23:59:59.123+00:00")
|
||||||
|
await async_setup_component(
|
||||||
|
hass,
|
||||||
|
BUTTON_DOMAIN,
|
||||||
|
{
|
||||||
|
BUTTON_DOMAIN: {
|
||||||
|
"platform": DOMAIN,
|
||||||
|
"entities": ["button.notify_light", "button.self_destruct"],
|
||||||
|
"name": "Button group",
|
||||||
|
"unique_id": "unique_identifier",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await hass.async_start()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("button.button_group")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_UNKNOWN
|
||||||
|
assert state.attributes.get(ATTR_ENTITY_ID) == [
|
||||||
|
"button.notify_light",
|
||||||
|
"button.self_destruct",
|
||||||
|
]
|
||||||
|
|
||||||
|
entry = entity_registry.async_get("button.button_group")
|
||||||
|
assert entry
|
||||||
|
assert entry.unique_id == "unique_identifier"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_state_reporting(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the state reporting.
|
||||||
|
|
||||||
|
The group state is unavailable if all group members are unavailable.
|
||||||
|
Otherwise, the group state represents the last time the grouped button was pressed.
|
||||||
|
"""
|
||||||
|
await async_setup_component(
|
||||||
|
hass,
|
||||||
|
BUTTON_DOMAIN,
|
||||||
|
{
|
||||||
|
BUTTON_DOMAIN: {
|
||||||
|
"platform": DOMAIN,
|
||||||
|
"entities": ["button.test1", "button.test2"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await hass.async_start()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Initial state with no group member in the state machine -> unavailable
|
||||||
|
assert hass.states.get("button.button_group").state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
# All group members unavailable -> unavailable
|
||||||
|
hass.states.async_set("button.test1", STATE_UNAVAILABLE)
|
||||||
|
hass.states.async_set("button.test2", STATE_UNAVAILABLE)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.states.get("button.button_group").state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
# All group members available, but no group member pressed -> unknown
|
||||||
|
hass.states.async_set("button.test1", "2021-01-01T23:59:59.123+00:00")
|
||||||
|
hass.states.async_set("button.test2", "2022-02-02T23:59:59.123+00:00")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.states.get("button.button_group").state == STATE_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("enable_custom_integrations")
|
||||||
|
async def test_service_calls(
|
||||||
|
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||||
|
) -> None:
|
||||||
|
"""Test service calls."""
|
||||||
|
await async_setup_component(
|
||||||
|
hass,
|
||||||
|
BUTTON_DOMAIN,
|
||||||
|
{
|
||||||
|
BUTTON_DOMAIN: [
|
||||||
|
{"platform": "demo"},
|
||||||
|
{
|
||||||
|
"platform": DOMAIN,
|
||||||
|
"entities": [
|
||||||
|
"button.push",
|
||||||
|
"button.self_destruct",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert hass.states.get("button.button_group").state == STATE_UNKNOWN
|
||||||
|
assert hass.states.get("button.push").state == STATE_UNKNOWN
|
||||||
|
|
||||||
|
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
|
||||||
|
freezer.move_to(now)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
BUTTON_DOMAIN,
|
||||||
|
SERVICE_PRESS,
|
||||||
|
{ATTR_ENTITY_ID: "button.button_group"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hass.states.get("button.button_group").state == now.isoformat()
|
||||||
|
assert hass.states.get("button.push").state == now.isoformat()
|
@ -29,6 +29,7 @@ from tests.typing import WebSocketGenerator
|
|||||||
[
|
[
|
||||||
("binary_sensor", "on", "on", {}, {}, {"all": False}, {}),
|
("binary_sensor", "on", "on", {}, {}, {"all": False}, {}),
|
||||||
("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}),
|
("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}),
|
||||||
|
("button", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}),
|
||||||
("cover", "open", "open", {}, {}, {}, {}),
|
("cover", "open", "open", {}, {}, {}, {}),
|
||||||
(
|
(
|
||||||
"event",
|
"event",
|
||||||
@ -135,6 +136,7 @@ async def test_config_flow(
|
|||||||
("group_type", "extra_input"),
|
("group_type", "extra_input"),
|
||||||
[
|
[
|
||||||
("binary_sensor", {"all": False}),
|
("binary_sensor", {"all": False}),
|
||||||
|
("button", {}),
|
||||||
("cover", {}),
|
("cover", {}),
|
||||||
("event", {}),
|
("event", {}),
|
||||||
("fan", {}),
|
("fan", {}),
|
||||||
@ -212,6 +214,7 @@ def get_suggested(schema, key):
|
|||||||
("group_type", "member_state", "extra_options", "options_options"),
|
("group_type", "member_state", "extra_options", "options_options"),
|
||||||
[
|
[
|
||||||
("binary_sensor", "on", {"all": False}, {}),
|
("binary_sensor", "on", {"all": False}, {}),
|
||||||
|
("button", "2021-01-01T23:59:59.123+00:00", {}, {}),
|
||||||
("cover", "open", {}, {}),
|
("cover", "open", {}, {}),
|
||||||
("event", "2021-01-01T23:59:59.123+00:00", {}, {}),
|
("event", "2021-01-01T23:59:59.123+00:00", {}, {}),
|
||||||
("fan", "on", {}, {}),
|
("fan", "on", {}, {}),
|
||||||
@ -396,6 +399,7 @@ async def test_all_options(
|
|||||||
("group_type", "extra_input"),
|
("group_type", "extra_input"),
|
||||||
[
|
[
|
||||||
("binary_sensor", {"all": False}),
|
("binary_sensor", {"all": False}),
|
||||||
|
("button", {}),
|
||||||
("cover", {}),
|
("cover", {}),
|
||||||
("event", {}),
|
("event", {}),
|
||||||
("fan", {}),
|
("fan", {}),
|
||||||
@ -491,6 +495,7 @@ SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"
|
|||||||
("domain", "extra_user_input", "input_states", "group_state", "extra_attributes"),
|
("domain", "extra_user_input", "input_states", "group_state", "extra_attributes"),
|
||||||
[
|
[
|
||||||
("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]),
|
("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]),
|
||||||
|
("button", {}, ["", ""], "unknown", [{}, {}]),
|
||||||
("cover", {}, ["open", "closed"], "open", COVER_ATTRS),
|
("cover", {}, ["open", "closed"], "open", COVER_ATTRS),
|
||||||
("event", {}, ["", ""], "unknown", EVENT_ATTRS),
|
("event", {}, ["", ""], "unknown", EVENT_ATTRS),
|
||||||
("fan", {}, ["on", "off"], "on", FAN_ATTRS),
|
("fan", {}, ["on", "off"], "on", FAN_ATTRS),
|
||||||
@ -600,6 +605,7 @@ async def test_config_flow_preview(
|
|||||||
),
|
),
|
||||||
[
|
[
|
||||||
("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", [{}, {}]),
|
("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", [{}, {}]),
|
||||||
|
("button", {}, {}, ["", ""], "unknown", [{}, {}]),
|
||||||
("cover", {}, {}, ["open", "closed"], "open", COVER_ATTRS),
|
("cover", {}, {}, ["open", "closed"], "open", COVER_ATTRS),
|
||||||
("event", {}, {}, ["", ""], "unknown", EVENT_ATTRS),
|
("event", {}, {}, ["", ""], "unknown", EVENT_ATTRS),
|
||||||
("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS),
|
("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user