diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 1d820b516af..28a7330a206 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -137,6 +137,7 @@ async def light_switch_options_schema( GROUP_TYPES = [ "binary_sensor", "cover", + "event", "fan", "light", "lock", @@ -178,6 +179,10 @@ CONFIG_FLOW = { basic_group_config_schema("cover"), validate_user_input=set_group_type("cover"), ), + "event": SchemaFlowFormStep( + basic_group_config_schema("event"), + validate_user_input=set_group_type("event"), + ), "fan": SchemaFlowFormStep( basic_group_config_schema("fan"), validate_user_input=set_group_type("fan"), @@ -213,6 +218,7 @@ OPTIONS_FLOW = { preview="group", ), "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), + "event": SchemaFlowFormStep(partial(basic_group_options_schema, "event")), "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), "lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")), diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py new file mode 100644 index 00000000000..81705c7f6f0 --- /dev/null +++ b/homeassistant/components/group/event.py @@ -0,0 +1,180 @@ +"""Platform allowing several event entities to be grouped into one event.""" +from __future__ import annotations + +import itertools + +import voluptuous as vol + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN, + PLATFORM_SCHEMA, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +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.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType + +from . import GroupEntity + +DEFAULT_NAME = "Event group" + +# No limit on parallel updates to enable a group calling another group +PARALLEL_UPDATES = 0 + +PLATFORM_SCHEMA = 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 event group platform.""" + async_add_entities( + [ + EventGroup( + 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 event group config entry.""" + registry = er.async_get(hass) + entities = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITIES] + ) + async_add_entities( + [ + EventGroup( + config_entry.entry_id, + config_entry.title, + entities, + ) + ] + ) + + +class EventGroup(GroupEntity, EventEntity): + """Representation of an event group.""" + + _attr_available = False + _attr_should_poll = False + + def __init__( + self, + unique_id: str | None, + name: str, + entity_ids: list[str], + ) -> None: + """Initialize an event 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 + self._attr_event_types = [] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: + """Handle child updates.""" + if not self.hass.is_running: + return + + self.async_set_context(event.context) + + # Update all properties of the group + self.async_update_group_state() + + # Re-fire if one of the members fires an event, but only + # if the original state was not unavailable or unknown. + if ( + (old_state := event.data["old_state"]) + and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and (new_state := event.data["new_state"]) + and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE)) + ): + event_attributes = new_state.attributes.copy() + + # We should not propagate the event properties as + # fired event attributes. + del event_attributes[ATTR_EVENT_TYPE] + del event_attributes[ATTR_EVENT_TYPES] + event_attributes.pop(ATTR_DEVICE_CLASS, None) + event_attributes.pop(ATTR_FRIENDLY_NAME, None) + + # Fire the group event + self._trigger_event(event_type, event_attributes) + + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + await super().async_added_to_hass() + + @callback + def async_update_group_state(self) -> None: + """Query all members and determine the event group properties.""" + states = [ + state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] + + # None of the members are available + if not states: + self._attr_available = False + return + + # Gather and combine all possible event types from all entities + self._attr_event_types = list( + set( + itertools.chain.from_iterable( + state.attributes.get(ATTR_EVENT_TYPES, []) for state in states + ) + ) + ) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 1c656b46b9e..5f3042c5bf7 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -8,6 +8,7 @@ "menu_options": { "binary_sensor": "Binary sensor group", "cover": "Cover group", + "event": "Event group", "fan": "Fan group", "light": "Light group", "lock": "Lock group", @@ -34,6 +35,14 @@ "name": "[%key:component::group::config::step::binary_sensor::data::name%]" } }, + "event": { + "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:component::group::config::step::binary_sensor::data::name%]" + } + }, "fan": { "title": "[%key:component::group::config::step::user::title%]", "data": { diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index a2845f098d3..b244b37e072 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.group import DOMAIN, async_setup_entry +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er @@ -28,6 +29,18 @@ from tests.typing import WebSocketGenerator ("binary_sensor", "on", "on", {}, {}, {"all": False}, {}), ("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}), ("cover", "open", "open", {}, {}, {}, {}), + ( + "event", + STATE_UNKNOWN, + "2021-01-01T23:59:59.123+00:00", + { + "event_type": "single_press", + "event_types": ["single_press", "double_press"], + }, + {}, + {}, + {}, + ), ("fan", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), @@ -122,6 +135,7 @@ async def test_config_flow( ( ("binary_sensor", {"all": False}), ("cover", {}), + ("event", {}), ("fan", {}), ("light", {}), ("lock", {}), @@ -194,6 +208,7 @@ def get_suggested(schema, key): ( ("binary_sensor", "on", {"all": False}, {}), ("cover", "open", {}, {}), + ("event", "2021-01-01T23:59:59.123+00:00", {}, {}), ("fan", "on", {}, {}), ("light", "on", {"all": False}, {}), ("lock", "locked", {}, {}), @@ -377,6 +392,7 @@ async def test_all_options( ( ("binary_sensor", {"all": False}), ("cover", {}), + ("event", {}), ("fan", {}), ("light", {}), ("lock", {}), diff --git a/tests/components/group/test_event.py b/tests/components/group/test_event.py new file mode 100644 index 00000000000..16ea11fe311 --- /dev/null +++ b/tests/components/group/test_event.py @@ -0,0 +1,138 @@ +"""The tests for the group event platform.""" + +from pytest_unordered import unordered + +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN +from homeassistant.components.event.const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.components.group import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + 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 + + +async def test_default_state(hass: HomeAssistant) -> None: + """Test event group default state.""" + await async_setup_component( + hass, + EVENT_DOMAIN, + { + EVENT_DOMAIN: { + "platform": DOMAIN, + "entities": ["event.button_1", "event.button_2"], + "name": "Remote control", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set( + "event.button_1", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "double_press", "event_types": ["single_press", "double_press"]}, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert not state.attributes.get(ATTR_EVENT_TYPE) + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press"] + ) + + # State changed + hass.states.async_set( + "event.button_1", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "single_press", "event_types": ["single_press", "double_press"]}, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press"] + ) + + # State changed, second remote came online + hass.states.async_set( + "event.button_2", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "double_press", "event_types": ["double_press", "triple_press"]}, + ) + await hass.async_block_till_done() + + # State should be single_press, because button coming online is not an event + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press", "triple_press"] + ) + + # State changed, now it fires an event + hass.states.async_set( + "event.button_2", + "2021-01-01T23:59:59.123+00:00", + { + "event_type": "triple_press", + "event_types": ["double_press", "triple_press"], + "device_class": "doorbell", + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "triple_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press", "triple_press"] + ) + assert ATTR_DEVICE_CLASS not in state.attributes + + # Mark button 1 unavailable + hass.states.async_set("event.button_1", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "triple_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["double_press", "triple_press"] + ) + + # Mark button 2 unavailable + hass.states.async_set("event.button_2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("event.remote_control") + assert entry + assert entry.unique_id == "unique_identifier"