From 81a8dee22a855f126a4af73507eb9fbc8d6695d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Mar 2026 08:20:21 +0100 Subject: [PATCH] Add event entity triggers (#165456) --- .../components/automation/__init__.py | 1 + homeassistant/components/event/icons.json | 5 + homeassistant/components/event/strings.json | 14 +- homeassistant/components/event/trigger.py | 67 ++++ homeassistant/components/event/triggers.yaml | 16 + homeassistant/helpers/trigger.py | 5 +- tests/components/event/test_trigger.py | 287 ++++++++++++++++++ 7 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/event/trigger.py create mode 100644 homeassistant/components/event/triggers.yaml create mode 100644 tests/components/event/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 92b128684e1..8d55680c86f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -153,6 +153,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "cover", "device_tracker", "door", + "event", "fan", "garage_door", "gate", diff --git a/homeassistant/components/event/icons.json b/homeassistant/components/event/icons.json index 92f2e7a6546..2a9837852ef 100644 --- a/homeassistant/components/event/icons.json +++ b/homeassistant/components/event/icons.json @@ -12,5 +12,10 @@ "motion": { "default": "mdi:motion-sensor" } + }, + "triggers": { + "received": { + "trigger": "mdi:eye-check" + } } } diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json index 8dc2abf6fef..bdf9144761c 100644 --- a/homeassistant/components/event/strings.json +++ b/homeassistant/components/event/strings.json @@ -21,5 +21,17 @@ "name": "Motion" } }, - "title": "Event" + "title": "Event", + "triggers": { + "received": { + "description": "Triggers after one or more event entities receive a matching event.", + "fields": { + "event_type": { + "description": "The event types to trigger on.", + "name": "Event type" + } + }, + "name": "Event received" + } + } } diff --git a/homeassistant/components/event/trigger.py b/homeassistant/components/event/trigger.py new file mode 100644 index 00000000000..aeff81988ba --- /dev/null +++ b/homeassistant/components/event/trigger.py @@ -0,0 +1,67 @@ +"""Provides triggers for events.""" + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + EntityTriggerBase, + Trigger, + TriggerConfig, +) + +from .const import ATTR_EVENT_TYPE, DOMAIN + +CONF_EVENT_TYPE = "event_type" + +EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_EVENT_TYPE): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + }, + } +) + + +class EventReceivedTrigger(EntityTriggerBase): + """Trigger for event entity when it receives a matching event.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = EVENT_RECEIVED_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the event received trigger.""" + super().__init__(hass, config) + self._event_types = set(self._options[CONF_EVENT_TYPE]) + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is valid and different from the current state.""" + + # UNKNOWN is a valid from_state, otherwise the first time the event is received + # would not trigger + if from_state.state == STATE_UNAVAILABLE: + return False + + return from_state.state != to_state.state + + def is_valid_state(self, state: State) -> bool: + """Check if the event type is valid and matches one of the configured types.""" + return ( + state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "received": EventReceivedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for events.""" + return TRIGGERS diff --git a/homeassistant/components/event/triggers.yaml b/homeassistant/components/event/triggers.yaml new file mode 100644 index 00000000000..c70cffbf468 --- /dev/null +++ b/homeassistant/components/event/triggers.yaml @@ -0,0 +1,16 @@ +received: + target: + entity: + domain: event + fields: + event_type: + context: + filter_target: target + required: true + selector: + state: + attribute: event_type + hide_states: + - unavailable + - unknown + multiple: true diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index ea59435ee6e..a25ef59357a 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -1295,7 +1295,10 @@ async def _async_get_trigger_platform( platform_and_sub_type = trigger_key.split(".") platform = platform_and_sub_type[0] - platform = _PLATFORM_ALIASES.get(platform, platform) + # Only apply aliases for old-style triggers (no sub_type). + # New-style triggers (e.g. "event.received") use the integration domain directly. + if len(platform_and_sub_type) == 1: + platform = _PLATFORM_ALIASES.get(platform, platform) if automation.is_disabled_experimental_trigger(hass, platform): raise vol.Invalid( diff --git a/tests/components/event/test_trigger.py b/tests/components/event/test_trigger.py new file mode 100644 index 00000000000..98800e16a26 --- /dev/null +++ b/tests/components/event/test_trigger.py @@ -0,0 +1,287 @@ +"""Test event trigger.""" + +import pytest + +from homeassistant.components.event.const import ATTR_EVENT_TYPE +from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components.common import ( + TriggerStateDescription, + arm_trigger, + assert_trigger_gated_by_labs_flag, + parametrize_target_entities, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture +async def target_events(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple event entities associated with different targets.""" + return await target_entities(hass, "event") + + +@pytest.mark.parametrize("trigger_key", ["event.received"]) +async def test_event_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the event triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag( + hass, + caplog, + trigger_key, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("event"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + # Event received with matching event_type + ( + "event.received", + {"event_type": ["button_press"]}, + [ + {"included_state": {"state": None, "attributes": {}}, "count": 0}, + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + ], + ), + # Event received with non-matching event_type then matching + ( + "event.received", + {"event_type": ["button_press"]}, + [ + {"included_state": {"state": None, "attributes": {}}, "count": 0}, + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "other_event"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + ], + ), + # Multiple event types configured + ( + "event.received", + {"event_type": ["button_press", "button_long_press"]}, + [ + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_long_press"}, + }, + "count": 1, + }, + { + "included_state": { + "state": "2026-01-01T00:00:02.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "other_event"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:03.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + ], + ), + # From unavailable - first valid state after unavailable is not triggered + ( + "event.received", + {"event_type": ["button_press"]}, + [ + { + "included_state": { + "state": STATE_UNAVAILABLE, + "attributes": {}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + ], + ), + # From unknown - first valid state after unknown is triggered + ( + "event.received", + {"event_type": ["button_press"]}, + [ + { + "included_state": { + "state": STATE_UNKNOWN, + "attributes": {}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + { + "included_state": { + "state": STATE_UNKNOWN, + "attributes": {}, + }, + "count": 0, + }, + ], + ), + # Same event type fires again (different timestamps) + ( + "event.received", + {"event_type": ["button_press"]}, + [ + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + { + "included_state": { + "state": "2026-01-01T00:00:02.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + ], + ), + # To unavailable - should not trigger, and first state after unavailable is skipped + ( + "event.received", + {"event_type": ["button_press"]}, + [ + { + "included_state": { + "state": "2026-01-01T00:00:00.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 0, + }, + { + "included_state": { + "state": STATE_UNAVAILABLE, + "attributes": {}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:01.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 0, + }, + { + "included_state": { + "state": "2026-01-01T00:00:02.000+00:00", + "attributes": {ATTR_EVENT_TYPE: "button_press"}, + }, + "count": 1, + }, + ], + ), + ], +) +async def test_event_state_trigger( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_events: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict, + states: list[TriggerStateDescription], +) -> None: + """Test that the event trigger fires when an event entity receives a matching event.""" + other_entity_ids = set(target_events["included_entities"]) - {entity_id} + + # Set all events to the initial state + for eid in target_events["included_entities"]: + set_or_remove_state(hass, eid, states[0]["included_state"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, trigger_options, trigger_target_config) + + for state in states[1:]: + included_state = state["included_state"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Check if changing other events also triggers + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear()