From fa13d645864a50d24baaa95a61867955ea90acbc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 11 Nov 2025 16:46:38 +0000 Subject: [PATCH] Add event.detected trigger --- homeassistant/components/event/icons.json | 5 + homeassistant/components/event/strings.json | 15 +- homeassistant/components/event/trigger.py | 116 ++++++++ homeassistant/components/event/triggers.yaml | 13 + homeassistant/helpers/trigger.py | 5 +- tests/components/event/test_trigger.py | 275 ++++++++++++++++++ .../homeassistant/triggers/test_event.py | 25 +- 7 files changed, 447 insertions(+), 7 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/event/icons.json b/homeassistant/components/event/icons.json index 92f2e7a6546a..adfb386de2c3 100644 --- a/homeassistant/components/event/icons.json +++ b/homeassistant/components/event/icons.json @@ -12,5 +12,10 @@ "motion": { "default": "mdi:motion-sensor" } + }, + "triggers": { + "detected": { + "trigger": "mdi:eye-check" + } } } diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json index 8dc2abf6fef6..f46b9cf86241 100644 --- a/homeassistant/components/event/strings.json +++ b/homeassistant/components/event/strings.json @@ -21,5 +21,18 @@ "name": "Motion" } }, - "title": "Event" + "title": "Event", + "triggers": { + "detected": { + "description": "Triggers when an event is detected.", + "description_configured": "Triggers when an event is detected", + "fields": { + "event_type": { + "description": "The event types to trigger on. If empty, triggers on all event types.", + "name": "Event types" + } + }, + "name": "When an event is detected" + } + } } diff --git a/homeassistant/components/event/trigger.py b/homeassistant/components/event/trigger.py new file mode 100644 index 000000000000..50f6098c2d16 --- /dev/null +++ b/homeassistant/components/event/trigger.py @@ -0,0 +1,116 @@ +"""Provides triggers for events.""" + +from typing import TYPE_CHECKING, cast, override + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_UNAVAILABLE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.target import ( + TargetStateChangedData, + async_track_target_selector_state_change_event, +) +from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig +from homeassistant.helpers.typing import ConfigType + +from .const import ATTR_EVENT_TYPE, DOMAIN + +EVENT_TRIGGER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_OPTIONS, default={}): { + vol.Optional(ATTR_EVENT_TYPE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + }, + vol.Required(CONF_TARGET): cv.TARGET_FIELDS, + } +) + + +class EventDetectedTrigger(Trigger): + """Trigger for when an event is detected.""" + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, EVENT_TRIGGER_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the event detected trigger.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + assert config.target is not None + self._options = config.options + self._target = config.target + + @override + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + event_types_filter = self._options[ATTR_EVENT_TYPE] + + @callback + def state_change_listener( + target_state_change_data: TargetStateChangedData, + ) -> None: + """Listen for state changes and call action.""" + event = target_state_change_data.state_change_event + entity_id = event.data["entity_id"] + from_state = event.data["old_state"] + to_state = event.data["new_state"] + + # Ignore unavailable states + if to_state is None or to_state.state == STATE_UNAVAILABLE: + return + + # Trigger on any state change (event detection) + # Events can have the same event_type triggered sequentially + + # If event_types filter is specified, check if the event_type matches + if event_types_filter: + event_type = to_state.attributes.get(ATTR_EVENT_TYPE) + if event_type not in event_types_filter: + return + + run_action( + { + ATTR_ENTITY_ID: entity_id, + "from_state": from_state, + "to_state": to_state, + }, + f"event detected on {entity_id}", + event.context, + ) + + def entity_filter(entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + return { + entity_id + for entity_id in entities + if split_entity_id(entity_id)[0] == DOMAIN + } + + return async_track_target_selector_state_change_event( + self._hass, self._target, state_change_listener, entity_filter + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": EventDetectedTrigger, +} + + +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 000000000000..4dcff6171091 --- /dev/null +++ b/homeassistant/components/event/triggers.yaml @@ -0,0 +1,13 @@ +detected: + target: + entity: + domain: event + fields: + event_type: + required: false + default: [] + selector: + select: + multiple: true + custom_value: true + options: [] diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 28eac8f632ad..65f925dd0752 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -467,7 +467,10 @@ async def _async_get_trigger_platform( ) -> tuple[str, TriggerProtocol]: platform_and_sub_type = trigger_key.split(".") platform = platform_and_sub_type[0] - platform = _PLATFORM_ALIASES.get(platform, platform) + # Only apply aliases if there's no sub-type specified + # This allows "event" → "homeassistant" but "event.detected" → "event" + if len(platform_and_sub_type) == 1: + platform = _PLATFORM_ALIASES.get(platform, platform) try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: diff --git a/tests/components/event/test_trigger.py b/tests/components/event/test_trigger.py new file mode 100644 index 000000000000..4ccb178d24ac --- /dev/null +++ b/tests/components/event/test_trigger.py @@ -0,0 +1,275 @@ +"""Test event trigger.""" + +from homeassistant.components import automation +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + + +async def test_event_detected_trigger( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test that the event detected trigger fires when an event is detected.""" + entity_id = "event.test_event" + await async_setup_component(hass, "event", {}) + + # Set initial state + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + "trigger": "event.detected", + "target": {CONF_ENTITY_ID: entity_id}, + }, + "actions": { + "action": "test.automation", + "data": { + CONF_ENTITY_ID: "{{ trigger.entity_id }}", + }, + }, + } + }, + ) + + # Trigger event + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Trigger same event type again - should still trigger + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + + +async def test_event_detected_trigger_with_event_type_filter( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test that the event detected trigger with event_type filter.""" + entity_id = "event.test_event" + await async_setup_component(hass, "event", {}) + + # Set initial state + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + "trigger": "event.detected", + "target": { + CONF_ENTITY_ID: entity_id, + }, + "options": { + "event_type": ["button_press", "button_hold"], + }, + }, + "actions": { + "action": "test.automation", + "data": { + CONF_ENTITY_ID: "{{ trigger.entity_id }}", + }, + }, + } + }, + ) + + # Trigger matching event type + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Trigger different matching event type + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_hold"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + # Trigger non-matching event type - should not trigger + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_release"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +async def test_event_detected_trigger_ignores_unavailable( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test that the event detected trigger ignores unavailable states.""" + entity_id = "event.test_event" + await async_setup_component(hass, "event", {}) + + # Set initial state + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + "trigger": "event.detected", + "target": { + CONF_ENTITY_ID: entity_id, + }, + }, + "actions": { + "action": "test.automation", + "data": { + CONF_ENTITY_ID: "{{ trigger.entity_id }}", + }, + }, + } + }, + ) + + # Set to unavailable - should not trigger + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Trigger event after unavailable - should trigger + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id + + +async def test_event_detected_trigger_sequential_same_event_type( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test that the event detected trigger fires for sequential events of the same type.""" + entity_id = "event.test_event" + await async_setup_component(hass, "event", {}) + + # Set initial state + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + "trigger": "event.detected", + "target": {CONF_ENTITY_ID: entity_id}, + }, + "actions": { + "action": "test.automation", + "data": {CONF_ENTITY_ID: entity_id}, + }, + } + }, + ) + + # Trigger same event type multiple times in a row + for _ in range(3): + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + + # Should have triggered 3 times + assert len(service_calls) == 3 + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + + +async def test_event_detected_trigger_from_unknown_state( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test that the trigger fires when entity goes from unknown/None to first event. + + Event entities restore their state, so on first creation they have no state. + """ + entity_id = "event.test_event" + await async_setup_component(hass, "event", {}) + + # Do NOT set any initial state - entity starts with None state + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + "trigger": "event.detected", + "target": {CONF_ENTITY_ID: entity_id}, + }, + "actions": { + "action": "test.automation", + "data": { + CONF_ENTITY_ID: "{{ trigger.entity_id }}", + }, + }, + } + }, + ) + + # First event should trigger even though entity had no previous state + hass.states.async_set( + entity_id, + dt_util.utcnow().isoformat(timespec="milliseconds"), + {ATTR_EVENT_TYPE: "button_press"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 5536db1eb5e9..a8aea1aa1023 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -33,7 +33,10 @@ async def test_if_fires_on_event( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": { + "platform": "event", + "event_type": "test_event", + }, "action": { "service": "test.automation", "data_template": {"id": "{{ trigger.id}}"}, @@ -73,7 +76,10 @@ async def test_if_fires_on_templated_event( { automation.DOMAIN: { "trigger_variables": {"event_type": "test_event"}, - "trigger": {"platform": "event", "event_type": "{{event_type}}"}, + "trigger": { + "platform": "event", + "event_type": "{{event_type}}", + }, "action": {"service": "test.automation"}, } }, @@ -135,7 +141,10 @@ async def test_if_fires_on_event_extra_data( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": { + "platform": "event", + "event_type": "test_event", + }, "action": {"service": "test.automation"}, } }, @@ -580,7 +589,10 @@ async def test_state_reported_event( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": event_type}, + "trigger": { + "platform": "event", + "event_type": event_type, + }, "action": { "service": "test.automation", "data_template": {"id": "{{ trigger.id}}"}, @@ -613,7 +625,10 @@ async def test_templated_state_reported_event( { automation.DOMAIN: { "trigger_variables": {"event_type": "state_reported"}, - "trigger": {"platform": "event", "event_type": "{{event_type}}"}, + "trigger": { + "platform": "event", + "event_type": "{{event_type}}", + }, "action": { "service": "test.automation", "data_template": {"id": "{{ trigger.id}}"},