From a953b697ce713ead06f2d8d5879b68eddeccfe1e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 10:22:31 +0100 Subject: [PATCH] Add valve conditions (#166634) --- .../components/automation/__init__.py | 1 + homeassistant/components/valve/condition.py | 20 +++ .../components/valve/conditions.yaml | 17 ++ homeassistant/components/valve/icons.json | 8 + homeassistant/components/valve/strings.json | 30 ++++ homeassistant/helpers/condition.py | 8 +- tests/components/valve/test_condition.py | 154 ++++++++++++++++++ 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/valve/condition.py create mode 100644 homeassistant/components/valve/conditions.yaml create mode 100644 tests/components/valve/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8d4fc2ebc12..bf768dba6a3 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -147,6 +147,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "temperature", "text", "vacuum", + "valve", "water_heater", "window", } diff --git a/homeassistant/components/valve/condition.py b/homeassistant/components/valve/condition.py new file mode 100644 index 00000000000..5ff94ee08ec --- /dev/null +++ b/homeassistant/components/valve/condition.py @@ -0,0 +1,20 @@ +"""Provides conditions for valves.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from . import ATTR_IS_CLOSED +from .const import DOMAIN + +VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)} + +CONDITIONS: dict[str, type[Condition]] = { + "is_open": make_entity_state_condition(VALVE_DOMAIN_SPECS, False), + "is_closed": make_entity_state_condition(VALVE_DOMAIN_SPECS, True), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the valve conditions.""" + return CONDITIONS diff --git a/homeassistant/components/valve/conditions.yaml b/homeassistant/components/valve/conditions.yaml new file mode 100644 index 00000000000..b639ae832e7 --- /dev/null +++ b/homeassistant/components/valve/conditions.yaml @@ -0,0 +1,17 @@ +.condition_common: &condition_common + target: + entity: + - domain: valve + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_open: *condition_common +is_closed: *condition_common diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index c9c6b632dcb..bc01ba77175 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_closed": { + "condition": "mdi:valve-closed" + }, + "is_open": { + "condition": "mdi:valve-open" + } + }, "entity_component": { "_": { "default": "mdi:valve-open", diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 10e5e302eba..09bd02ba207 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,4 +1,26 @@ { + "conditions": { + "is_closed": { + "description": "Tests if one or more valves are closed.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + } + }, + "name": "Valve is closed" + }, + "is_open": { + "description": "Tests if one or more valves are open.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + } + }, + "name": "Valve is open" + } + }, "entity_component": { "_": { "name": "[%key:component::valve::title%]", @@ -22,6 +44,14 @@ "name": "Water" } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "close_valve": { "description": "Closes a valve.", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index e71dc1b991b..5cf8df5d36c 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -421,7 +421,7 @@ class EntityConditionBase(Condition): class EntityStateConditionBase(EntityConditionBase): """State condition.""" - _states: set[str] + _states: set[str | bool] def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" @@ -439,7 +439,7 @@ def _normalize_domain_specs( def make_entity_state_condition( domain_specs: Mapping[str, DomainSpec] | str, - states: str | set[str], + states: str | bool | set[str | bool], ) -> type[EntityStateConditionBase]: """Create a condition for entity state changes to specific state(s). @@ -448,8 +448,8 @@ def make_entity_state_condition( """ specs = _normalize_domain_specs(domain_specs) - if isinstance(states, str): - states_set = {states} + if isinstance(states, (str, bool)): + states_set: set[str | bool] = {states} else: states_set = states diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py new file mode 100644 index 00000000000..5ec78a90229 --- /dev/null +++ b/tests/components/valve/test_condition.py @@ -0,0 +1,154 @@ +"""Test valve conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.valve import ATTR_IS_CLOSED +from homeassistant.components.valve.const import ValveState +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@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, "valve") + + +@pytest.mark.parametrize( + "condition", + [ + "valve.is_open", + "valve.is_closed", + ], +) +async def test_valve_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the valve conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("valve"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="valve.is_open", + 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}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + ), + *parametrize_condition_states_any( + condition="valve.is_closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + ), + ], +) +async def test_valve_condition_behavior_any( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test valve condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_valves, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("valve"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="valve.is_open", + 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}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + ), + *parametrize_condition_states_all( + condition="valve.is_closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + ), + ], +) +async def test_valve_condition_behavior_all( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test valve condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_valves, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + )