From 29980d69b56e4b0cf522521d0147bda3d8ba652c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:06:43 +0200 Subject: [PATCH] Add `valve.opened` and `valve.closed` triggers (#165160) --- .../components/automation/__init__.py | 1 + homeassistant/components/valve/icons.json | 8 + homeassistant/components/valve/strings.json | 35 +++- homeassistant/components/valve/trigger.py | 24 +++ homeassistant/components/valve/triggers.yaml | 18 ++ homeassistant/helpers/trigger.py | 8 +- tests/components/valve/test_trigger.py | 161 ++++++++++++++++++ 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/valve/trigger.py create mode 100644 homeassistant/components/valve/triggers.yaml create mode 100644 tests/components/valve/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fcd881581d4..8980ced5bbd 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -191,6 +191,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "todo", "update", "vacuum", + "valve", "water_heater", "window", } diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index bc01ba77175..c5bccd46b14 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -40,5 +40,13 @@ "toggle": { "service": "mdi:valve-open" } + }, + "triggers": { + "closed": { + "trigger": "mdi:valve-closed" + }, + "opened": { + "trigger": "mdi:valve-open" + } } } diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 09bd02ba207..cd01e3142cf 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted valves to trigger on.", + "trigger_behavior_name": "Behavior" + }, "conditions": { "is_closed": { "description": "Tests if one or more valves are closed.", @@ -50,6 +54,13 @@ "all": "All", "any": "Any" } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } } }, "services": { @@ -80,5 +91,27 @@ "name": "Toggle valve" } }, - "title": "Valve" + "title": "Valve", + "triggers": { + "closed": { + "description": "Triggers after one or more valves close.", + "fields": { + "behavior": { + "description": "[%key:component::valve::common::trigger_behavior_description%]", + "name": "[%key:component::valve::common::trigger_behavior_name%]" + } + }, + "name": "Valve closed" + }, + "opened": { + "description": "Triggers after one or more valves open.", + "fields": { + "behavior": { + "description": "[%key:component::valve::common::trigger_behavior_description%]", + "name": "[%key:component::valve::common::trigger_behavior_name%]" + } + }, + "name": "Valve opened" + } + } } diff --git a/homeassistant/components/valve/trigger.py b/homeassistant/components/valve/trigger.py new file mode 100644 index 00000000000..8459accd4eb --- /dev/null +++ b/homeassistant/components/valve/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for valves.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger + +from . import ATTR_IS_CLOSED, DOMAIN + +VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)} + + +TRIGGERS: dict[str, type[Trigger]] = { + "closed": make_entity_transition_trigger( + VALVE_DOMAIN_SPECS, from_states={False}, to_states={True} + ), + "opened": make_entity_transition_trigger( + VALVE_DOMAIN_SPECS, from_states={True}, to_states={False} + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for valves.""" + return TRIGGERS diff --git a/homeassistant/components/valve/triggers.yaml b/homeassistant/components/valve/triggers.yaml new file mode 100644 index 00000000000..aaf09598d65 --- /dev/null +++ b/homeassistant/components/valve/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: valve + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: *trigger_common +opened: *trigger_common diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 404051fd5fc..99dd07ac75f 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -482,8 +482,8 @@ class EntityTargetStateTriggerBase(EntityTriggerBase): class EntityTransitionTriggerBase(EntityTriggerBase): """Trigger for entity state changes between specific states.""" - _from_states: set[str] - _to_states: set[str] + _from_states: set[str | bool] + _to_states: set[str | bool] def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check if the origin state matches the expected ones.""" @@ -838,8 +838,8 @@ def make_entity_target_state_trigger( def make_entity_transition_trigger( domain_specs: Mapping[str, DomainSpec] | str, *, - from_states: set[str], - to_states: set[str], + from_states: set[str | bool], + to_states: set[str | bool], ) -> type[EntityTransitionTriggerBase]: """Create a trigger for entity state changes between specific states. diff --git a/tests/components/valve/test_trigger.py b/tests/components/valve/test_trigger.py new file mode 100644 index 00000000000..ae0a1f038e4 --- /dev/null +++ b/tests/components/valve/test_trigger.py @@ -0,0 +1,161 @@ +"""Test valve trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.valve import ATTR_IS_CLOSED, DOMAIN, ValveState +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + +TRIGGER_STATES = [ + *parametrize_trigger_states( + trigger="valve.closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + (ValveState.OPENING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: None}), + (ValveState.OPEN, {}), + ], + ), + *parametrize_trigger_states( + trigger="valve.opened", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + ], + extra_invalid_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: None}), + (ValveState.CLOSED, {}), + ], + ), +] + + +@pytest.fixture +async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple valve entities associated with different targets.""" + return await target_entities(hass, DOMAIN) + + +@pytest.mark.parametrize( + "trigger_key", + [ + "valve.closed", + "valve.opened", + ], +) +async def test_valve_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the valve 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(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_any( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any] | None, + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when any valve state changes to a specific state.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_first( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entities_in_target: int, + entity_id: str, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when the first valve changes to a specific state.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_last( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entities_in_target: int, + entity_id: str, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when the last valve changes to a specific state.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + )