From c95416cb48e7a9eac40f56dcfba9b026a7bbc88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 21 Dec 2025 01:07:00 +0000 Subject: [PATCH] Add scene activated trigger (#159226) Co-authored-by: Erik Montnemery --- .../components/automation/__init__.py | 1 + homeassistant/components/scene/icons.json | 5 + homeassistant/components/scene/strings.json | 8 +- homeassistant/components/scene/trigger.py | 42 ++++ homeassistant/components/scene/triggers.yaml | 4 + tests/components/scene/test_trigger.py | 192 ++++++++++++++++++ 6 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/scene/trigger.py create mode 100644 homeassistant/components/scene/triggers.yaml create mode 100644 tests/components/scene/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4822387d407..03cfe6065b5 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -136,6 +136,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "light", "lock", "media_player", + "scene", "siren", "switch", "text", diff --git a/homeassistant/components/scene/icons.json b/homeassistant/components/scene/icons.json index aa5482d033f..8a2e1992a3c 100644 --- a/homeassistant/components/scene/icons.json +++ b/homeassistant/components/scene/icons.json @@ -20,5 +20,10 @@ "turn_on": { "service": "mdi:power" } + }, + "triggers": { + "activated": { + "trigger": "mdi:palette" + } } } diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index d40df919440..e631ab35010 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -59,5 +59,11 @@ "name": "Activate" } }, - "title": "Scene" + "title": "Scene", + "triggers": { + "activated": { + "description": "Triggers when a scene was activated", + "name": "Scene activated" + } + } } diff --git a/homeassistant/components/scene/trigger.py b/homeassistant/components/scene/trigger.py new file mode 100644 index 00000000000..c5537b15812 --- /dev/null +++ b/homeassistant/components/scene/trigger.py @@ -0,0 +1,42 @@ +"""Provides triggers for scenes.""" + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + EntityTriggerBase, + Trigger, +) + +from . import DOMAIN + + +class SceneActivatedTrigger(EntityTriggerBase): + """Trigger for scene entity activations.""" + + _domain = DOMAIN + _schema = ENTITY_STATE_TRIGGER_SCHEMA + + 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 scene is activated + # it 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 new state is not invalid.""" + return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + +TRIGGERS: dict[str, type[Trigger]] = { + "activated": SceneActivatedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for scenes.""" + return TRIGGERS diff --git a/homeassistant/components/scene/triggers.yaml b/homeassistant/components/scene/triggers.yaml new file mode 100644 index 00000000000..a2143b711ff --- /dev/null +++ b/homeassistant/components/scene/triggers.yaml @@ -0,0 +1,4 @@ +activated: + target: + entity: + domain: scene diff --git a/tests/components/scene/test_trigger.py b/tests/components/scene/test_trigger.py new file mode 100644 index 00000000000..1361e211a5f --- /dev/null +++ b/tests/components/scene/test_trigger.py @@ -0,0 +1,192 @@ +"""Test scene trigger.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import ( + ATTR_LABEL_ID, + CONF_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_scenes(hass: HomeAssistant) -> list[str]: + """Create multiple scene entities associated with different targets.""" + return (await target_entities(hass, "scene"))["included"] + + +@pytest.mark.parametrize("trigger_key", ["scene.activated"]) +async def test_scene_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the scene triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("scene"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + ( + "scene.activated", + [ + {"included": {"state": None, "attributes": {}}, "count": 0}, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 0, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + ], + ), + ( + "scene.activated", + [ + {"included": {"state": "foo", "attributes": {}}, "count": 0}, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + ], + ), + ( + "scene.activated", + [ + { + "included": {"state": STATE_UNAVAILABLE, "attributes": {}}, + "count": 0, + }, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 0, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + { + "included": {"state": STATE_UNAVAILABLE, "attributes": {}}, + "count": 0, + }, + ], + ), + ( + "scene.activated", + [ + {"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0}, + { + "included": { + "state": "2021-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + { + "included": { + "state": "2022-01-01T23:59:59+00:00", + "attributes": {}, + }, + "count": 1, + }, + {"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0}, + ], + ), + ], +) +async def test_scene_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_scenes: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the scene state trigger fires when any scene state changes to a specific state.""" + other_entity_ids = set(target_scenes) - {entity_id} + + # Set all scenes, including the tested scene, to the initial state + for eid in target_scenes: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, None, trigger_target_config) + + for state in states[1:]: + included_state = state["included"] + 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 scenes 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()