Skip to content

Commit

Permalink
Add button group support (home-assistant#121715)
Browse files Browse the repository at this point in the history
Co-authored-by: G Johansson <[email protected]>
  • Loading branch information
frenck and gjohansson-ST authored Jul 11, 2024
1 parent acb4a92 commit f94b28f
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 0 deletions.
131 changes: 131 additions & 0 deletions homeassistant/components/group/button.py
Original file line number Diff line number Diff line change
@@ -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
)
12 changes: 12 additions & 0 deletions homeassistant/components/group/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)

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
Expand Down Expand Up @@ -146,6 +147,7 @@ async def light_switch_options_schema(

GROUP_TYPES = [
"binary_sensor",
"button",
"cover",
"event",
"fan",
Expand Down Expand Up @@ -185,6 +187,11 @@ async def _set_group_type(
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",
Expand Down Expand Up @@ -234,6 +241,10 @@ async def _set_group_type(
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",
Expand Down Expand Up @@ -275,6 +286,7 @@ async def _set_group_type(
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,
Expand Down
15 changes: 15 additions & 0 deletions homeassistant/components/group/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down Expand Up @@ -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%]",
Expand Down
122 changes: 122 additions & 0 deletions tests/components/group/test_button.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions tests/components/group/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
[
("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",
Expand Down Expand Up @@ -135,6 +136,7 @@ async def test_config_flow(
("group_type", "extra_input"),
[
("binary_sensor", {"all": False}),
("button", {}),
("cover", {}),
("event", {}),
("fan", {}),
Expand Down Expand Up @@ -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", {}, {}),
Expand Down Expand Up @@ -396,6 +399,7 @@ async def test_all_options(
("group_type", "extra_input"),
[
("binary_sensor", {"all": False}),
("button", {}),
("cover", {}),
("event", {}),
("fan", {}),
Expand Down Expand Up @@ -491,6 +495,7 @@ async def test_options_flow_hides_members(
("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),
Expand Down Expand Up @@ -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),
Expand Down

0 comments on commit f94b28f

Please sign in to comment.