From ee9d9781ee8eccf23842c5c8136f3116efb3a4e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:24:27 +0100 Subject: [PATCH] Add climate.is_hvac_mode condition (#166570) --- homeassistant/components/climate/condition.py | 41 ++++++++++++++++++- .../components/climate/conditions.yaml | 15 +++++++ homeassistant/components/climate/icons.json | 3 ++ homeassistant/components/climate/strings.json | 14 +++++++ tests/components/climate/test_condition.py | 37 +++++++++++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 8279b9bf583..0d1b5803b59 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -1,10 +1,18 @@ """Provides conditions for climates.""" -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, + ConditionConfig, + EntityConditionBase, EntityNumericalConditionWithUnitBase, make_entity_numerical_condition, make_entity_state_condition, @@ -13,6 +21,36 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode +CONF_HVAC_MODE = "hvac_mode" + +_HVAC_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_HVAC_MODE): vol.All( + cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)] + ), + }, + } +) + + +class ClimateHVACModeCondition(EntityConditionBase): + """Condition for climate HVAC mode.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = _HVAC_MODE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the HVAC mode condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._hvac_modes: set[str] = set(config.options[CONF_HVAC_MODE]) + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches any of the expected HVAC modes.""" + return entity_state.state in self._hvac_modes + class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Mixin for climate target temperature conditions with unit conversion.""" @@ -28,6 +66,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): CONDITIONS: dict[str, type[Condition]] = { + "is_hvac_mode": ClimateHVACModeCondition, "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), "is_on": make_entity_state_condition( DOMAIN, diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index 771d5e96332..cb1e09abac0 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/conditions.yaml @@ -45,6 +45,21 @@ is_cooling: *condition_common is_drying: *condition_common is_heating: *condition_common +is_hvac_mode: + target: *condition_climate_target + fields: + behavior: *condition_behavior + hvac_mode: + context: + filter_target: target + required: true + selector: + state: + hide_states: + - unavailable + - unknown + multiple: true + target_humidity: target: *condition_climate_target fields: diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 3300deb17e9..b88d4ba63f2 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -9,6 +9,9 @@ "is_heating": { "condition": "mdi:fire" }, + "is_hvac_mode": { + "condition": "mdi:thermostat" + }, "is_off": { "condition": "mdi:power-off" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index ec6c99e51ab..7fc608ff419 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -41,6 +41,20 @@ }, "name": "Climate-control device is heating" }, + "is_hvac_mode": { + "description": "Tests if one or more climate-control devices are set to a specific HVAC mode.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "hvac_mode": { + "description": "The HVAC modes to test for.", + "name": "Modes" + } + }, + "name": "Climate-control device HVAC mode" + }, "is_off": { "description": "Tests if one or more climate-control devices are off.", "fields": { diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 2d1305b4850..13bf598241a 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -47,6 +47,7 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: "climate.is_cooling", "climate.is_drying", "climate.is_heating", + "climate.is_hvac_mode", "climate.target_humidity", "climate.target_temperature", ], @@ -83,6 +84,24 @@ async def test_climate_conditions_gated_by_labs_flag( ], other_states=[HVACMode.OFF], ), + *( + param + for mode in HVACMode + for param in parametrize_condition_states_any( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [mode]}, + target_states=[mode], + other_states=[m for m in HVACMode if m != mode], + ) + ), + *parametrize_condition_states_any( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]}, + target_states=[HVACMode.HEAT, HVACMode.COOL], + other_states=[ + m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL) + ], + ), ], ) async def test_climate_state_condition_behavior_any( @@ -133,6 +152,24 @@ async def test_climate_state_condition_behavior_any( ], other_states=[HVACMode.OFF], ), + *( + param + for mode in HVACMode + for param in parametrize_condition_states_all( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [mode]}, + target_states=[mode], + other_states=[m for m in HVACMode if m != mode], + ) + ), + *parametrize_condition_states_all( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]}, + target_states=[HVACMode.HEAT, HVACMode.COOL], + other_states=[ + m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL) + ], + ), ], ) async def test_climate_state_condition_behavior_all(