From 68b4ad722d83ac42c7a659565240c60571c0e7e8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 27 Jan 2026 07:53:31 +0100 Subject: [PATCH] Add climate conditions (#161020) Co-authored-by: Martin Hjelmare --- .../components/automation/__init__.py | 1 + homeassistant/components/climate/condition.py | 39 +++ .../components/climate/conditions.yaml | 20 ++ homeassistant/components/climate/icons.json | 17 + homeassistant/components/climate/strings.json | 60 ++++ homeassistant/helpers/condition.py | 64 +++- tests/components/climate/test_condition.py | 305 ++++++++++++++++++ 7 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/climate/condition.py create mode 100644 homeassistant/components/climate/conditions.yaml create mode 100644 tests/components/climate/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 56b86f541df..f9fa9d576d5 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -125,6 +125,7 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions" _EXPERIMENTAL_CONDITION_PLATFORMS = { "alarm_control_panel", "assist_satellite", + "climate", "device_tracker", "fan", "lawn_mower", diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py new file mode 100644 index 00000000000..e1cee4ede99 --- /dev/null +++ b/homeassistant/components/climate/condition.py @@ -0,0 +1,39 @@ +"""Provides conditions for climates.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import ( + Condition, + make_entity_state_attribute_condition, + make_entity_state_condition, +) + +from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode + +CONDITIONS: dict[str, type[Condition]] = { + "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), + "is_on": make_entity_state_condition( + DOMAIN, + { + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + HVACMode.HEAT_COOL, + }, + ), + "is_cooling": make_entity_state_attribute_condition( + DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING + ), + "is_drying": make_entity_state_attribute_condition( + DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING + ), + "is_heating": make_entity_state_attribute_condition( + DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the climate conditions.""" + return CONDITIONS diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml new file mode 100644 index 00000000000..284e128583b --- /dev/null +++ b/homeassistant/components/climate/conditions.yaml @@ -0,0 +1,20 @@ +.condition_common: &condition_common + target: + entity: + domain: climate + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_off: *condition_common +is_on: *condition_common +is_cooling: *condition_common +is_drying: *condition_common +is_heating: *condition_common diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 636df375d66..35a9d7cbea9 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -1,4 +1,21 @@ { + "conditions": { + "is_cooling": { + "condition": "mdi:snowflake" + }, + "is_drying": { + "condition": "mdi:water-percent" + }, + "is_heating": { + "condition": "mdi:fire" + }, + "is_off": { + "condition": "mdi:power-off" + }, + "is_on": { + "condition": "mdi:power-on" + } + }, "entity_component": { "_": { "default": "mdi:thermostat", diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 819d2591765..06b9ef3407e 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -1,8 +1,62 @@ { "common": { + "condition_behavior_description": "How the state should match on the targeted climate-control devices.", + "condition_behavior_name": "Behavior", "trigger_behavior_description": "The behavior of the targeted climates to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_cooling": { + "description": "Tests if one or more climate-control devices are cooling.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + } + }, + "name": "Climate-control device is cooling" + }, + "is_drying": { + "description": "Tests if one or more climate-control devices are drying.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + } + }, + "name": "Climate-control device is drying" + }, + "is_heating": { + "description": "Tests if one or more climate-control devices are heating.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + } + }, + "name": "Climate-control device is heating" + }, + "is_off": { + "description": "Tests if one or more climate-control devices are off.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + } + }, + "name": "Climate-control device is off" + }, + "is_on": { + "description": "Tests if one or more climate-control devices are on.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + } + }, + "name": "Climate-control device is on" + } + }, "device_automation": { "action_type": { "set_hvac_mode": "Change HVAC mode on {entity_name}", @@ -181,6 +235,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "hvac_mode": { "options": { "auto": "[%key:common::state::auto%]", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 2c82756c0a4..865a899bcd7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -331,12 +331,11 @@ ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema( ) -class EntityStateConditionBase(Condition): - """State condition.""" +class EntityConditionBase(Condition): + """Base class for entity conditions.""" _domain: str _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL - _states: set[str] @override @classmethod @@ -363,19 +362,23 @@ class EntityStateConditionBase(Condition): if split_entity_id(entity_id)[0] == self._domain } + @abc.abstractmethod + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches the expected state(s).""" + @override async def async_get_checker(self) -> ConditionChecker: """Get the condition checker.""" - def check_any_match_state(states: list[str]) -> bool: - """Test if any entity match the state.""" - return any(state in self._states for state in states) + def check_any_match_state(states: list[State]) -> bool: + """Test if any entity matches the state.""" + return any(self.is_valid_state(state) for state in states) - def check_all_match_state(states: list[str]) -> bool: + def check_all_match_state(states: list[State]) -> bool: """Test if all entities match the state.""" - return all(state in self._states for state in states) + return all(self.is_valid_state(state) for state in states) - matcher: Callable[[list[str]], bool] + matcher: Callable[[list[State]], bool] if self._behavior == BEHAVIOR_ANY: matcher = check_any_match_state elif self._behavior == BEHAVIOR_ALL: @@ -391,7 +394,7 @@ class EntityStateConditionBase(Condition): ) filtered_entity_ids = self.entity_filter(referenced_entity_ids) entity_states = [ - _state.state + _state for entity_id in filtered_entity_ids if (_state := self._hass.states.get(entity_id)) and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) @@ -401,6 +404,16 @@ class EntityStateConditionBase(Condition): return test_state +class EntityStateConditionBase(EntityConditionBase): + """State condition.""" + + _states: set[str] + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches the expected state(s).""" + return entity_state.state in self._states + + def make_entity_state_condition( domain: str, states: str | set[str] ) -> type[EntityStateConditionBase]: @@ -420,6 +433,37 @@ def make_entity_state_condition( return CustomCondition +class EntityStateAttributeConditionBase(EntityConditionBase): + """State attribute condition.""" + + _attribute: str + _attribute_states: set[str] + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches the expected state(s).""" + return entity_state.attributes.get(self._attribute) in self._attribute_states + + +def make_entity_state_attribute_condition( + domain: str, attribute: str, attribute_states: str | set[str] +) -> type[EntityStateAttributeConditionBase]: + """Create a condition for entity attribute matching specific state(s).""" + + if isinstance(attribute_states, str): + attribute_states_set = {attribute_states} + else: + attribute_states_set = attribute_states + + class CustomCondition(EntityStateAttributeConditionBase): + """Condition for entity attribute.""" + + _domain = domain + _attribute = attribute + _attribute_states = attribute_states_set + + return CustomCondition + + class ConditionProtocol(Protocol): """Define the format of condition modules.""" diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py new file mode 100644 index 00000000000..9a36b6c8649 --- /dev/null +++ b/tests/components/climate/test_condition.py @@ -0,0 +1,305 @@ +"""Test climate conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.climate.const import ( + ATTR_HVAC_ACTION, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant + +from tests.components import ( + ConditionStateDescription, + assert_condition_gated_by_labs_flag, + create_target_condition, + other_states, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture +async def target_climates(hass: HomeAssistant) -> list[str]: + """Create multiple climate entities associated with different targets.""" + return (await target_entities(hass, "climate"))["included"] + + +@pytest.mark.parametrize( + "condition", + [ + "climate.is_off", + "climate.is_on", + "climate.is_cooling", + "climate.is_drying", + "climate.is_heating", + ], +) +async def test_climate_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the climate 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("climate"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="climate.is_off", + target_states=[HVACMode.OFF], + other_states=other_states(HVACMode.OFF), + ), + *parametrize_condition_states_any( + condition="climate.is_on", + target_states=[ + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + HVACMode.HEAT_COOL, + ], + other_states=[HVACMode.OFF], + ), + ], +) +async def test_climate_state_condition_behavior_any( + hass: HomeAssistant, + target_climates: 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 the climate state condition with the 'any' behavior.""" + other_entity_ids = set(target_climates) - {entity_id} + + # Set all climates, including the tested climate, to the initial state + for eid in target_climates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="any", + ) + + for state in states: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + # Check if changing other climates also passes the condition + 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 condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("climate"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="climate.is_off", + target_states=[HVACMode.OFF], + other_states=other_states(HVACMode.OFF), + ), + *parametrize_condition_states_all( + condition="climate.is_on", + target_states=[ + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + HVACMode.HEAT_COOL, + ], + other_states=[HVACMode.OFF], + ), + ], +) +async def test_climate_state_condition_behavior_all( + hass: HomeAssistant, + target_climates: 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 the climate state condition with the 'all' behavior.""" + other_entity_ids = set(target_climates) - {entity_id} + + # Set all climates, including the tested climate, to the initial state + for eid in target_climates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="all", + ) + + for state in states: + included_state = state["included"] + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true_first_entity"] + + 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 condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("climate"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="climate.is_cooling", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_condition_states_any( + condition="climate.is_drying", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_condition_states_any( + condition="climate.is_heating", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + ], +) +async def test_climate_attribute_condition_behavior_any( + hass: HomeAssistant, + target_climates: 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 the climate attribute condition with the 'any' behavior.""" + other_entity_ids = set(target_climates) - {entity_id} + + # Set all climates, including the tested climate, to the initial state + for eid in target_climates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="any", + ) + + for state in states: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + # Check if changing other climates also passes the condition + 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 condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("climate"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="climate.is_cooling", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_condition_states_all( + condition="climate.is_drying", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_condition_states_all( + condition="climate.is_heating", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + ], +) +async def test_climate_attribute_condition_behavior_all( + hass: HomeAssistant, + target_climates: 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 the climate attribute condition with the 'all' behavior.""" + other_entity_ids = set(target_climates) - {entity_id} + + # Set all climates, including the tested climate, to the initial state + for eid in target_climates: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="all", + ) + + for state in states: + included_state = state["included"] + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true_first_entity"] + + 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 condition(hass) == state["condition_true"]