Add button group support (#121715)

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Franck Nijhof 2024-07-11 09:37:32 +02:00 committed by GitHub
parent acb4a92628
commit f94b28f72d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 286 additions and 0 deletions

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

View file

@ -23,6 +23,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
)
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 .cover import async_create_preview_cover
from .entity import GroupEntity
@ -146,6 +147,7 @@ async def light_switch_options_schema(
GROUP_TYPES = [
"binary_sensor",
"button",
"cover",
"event",
"fan",
@ -185,6 +187,11 @@ CONFIG_FLOW = {
preview="group",
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(
basic_group_config_schema("cover"),
preview="group",
@ -234,6 +241,10 @@ OPTIONS_FLOW = {
binary_sensor_options_schema,
preview="group",
),
"button": SchemaFlowFormStep(
partial(basic_group_options_schema, "button"),
preview="group",
),
"cover": SchemaFlowFormStep(
partial(basic_group_options_schema, "cover"),
preview="group",
@ -275,6 +286,7 @@ CREATE_PREVIEW_ENTITY: dict[
Callable[[HomeAssistant, str, dict[str, Any]], GroupEntity | MediaPlayerGroup],
] = {
"binary_sensor": async_create_preview_binary_sensor,
"button": async_create_preview_button,
"cover": async_create_preview_cover,
"event": async_create_preview_event,
"fan": async_create_preview_fan,

View file

@ -7,6 +7,7 @@
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
"menu_options": {
"binary_sensor": "Binary sensor group",
"button": "Button group",
"cover": "Cover group",
"event": "Event group",
"fan": "Fan group",
@ -27,6 +28,14 @@
"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": {
"title": "[%key:component::group::config::step::user::title%]",
"data": {
@ -109,6 +118,12 @@
"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": {
"data": {
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",

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

View file

@ -29,6 +29,7 @@ from tests.typing import WebSocketGenerator
[
("binary_sensor", "on", "on", {}, {}, {"all": False}, {}),
("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}),
("button", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}),
("cover", "open", "open", {}, {}, {}, {}),
(
"event",
@ -135,6 +136,7 @@ async def test_config_flow(
("group_type", "extra_input"),
[
("binary_sensor", {"all": False}),
("button", {}),
("cover", {}),
("event", {}),
("fan", {}),
@ -212,6 +214,7 @@ def get_suggested(schema, key):
("group_type", "member_state", "extra_options", "options_options"),
[
("binary_sensor", "on", {"all": False}, {}),
("button", "2021-01-01T23:59:59.123+00:00", {}, {}),
("cover", "open", {}, {}),
("event", "2021-01-01T23:59:59.123+00:00", {}, {}),
("fan", "on", {}, {}),
@ -396,6 +399,7 @@ async def test_all_options(
("group_type", "extra_input"),
[
("binary_sensor", {"all": False}),
("button", {}),
("cover", {}),
("event", {}),
("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"),
[
("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]),
("button", {}, ["", ""], "unknown", [{}, {}]),
("cover", {}, ["open", "closed"], "open", COVER_ATTRS),
("event", {}, ["", ""], "unknown", EVENT_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", [{}, {}]),
("button", {}, {}, ["", ""], "unknown", [{}, {}]),
("cover", {}, {}, ["open", "closed"], "open", COVER_ATTRS),
("event", {}, {}, ["", ""], "unknown", EVENT_ATTRS),
("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS),