diff --git a/homeassistant/components/group/button.py b/homeassistant/components/group/button.py new file mode 100644 index 00000000000..d8481686615 --- /dev/null +++ b/homeassistant/components/group/button.py @@ -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 + ) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 4eb0f1cdd52..54ef7d0626f 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -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, diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index bff1f1e22ec..dc850804d94 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -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%]", diff --git a/tests/components/group/test_button.py b/tests/components/group/test_button.py new file mode 100644 index 00000000000..c3f4a720d53 --- /dev/null +++ b/tests/components/group/test_button.py @@ -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() diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index c6ee4ae5a87..dc40b647e2e 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -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),