mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 02:24:46 -05:00
Add event.detected trigger
This commit is contained in:
@@ -12,5 +12,10 @@
|
||||
"motion": {
|
||||
"default": "mdi:motion-sensor"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"detected": {
|
||||
"trigger": "mdi:eye-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
detected:
|
||||
target:
|
||||
entity:
|
||||
domain: event
|
||||
fields:
|
||||
event_type:
|
||||
required: false
|
||||
default: []
|
||||
selector:
|
||||
select:
|
||||
multiple: true
|
||||
custom_value: true
|
||||
options: []
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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}}"},
|
||||
|
||||
Reference in New Issue
Block a user