1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

Add trigger climate.hvac_mode_changed (#159358)

This commit is contained in:
Erik Montnemery
2025-12-19 12:57:01 +01:00
committed by GitHub
parent 5154418051
commit 6cc7d83def
9 changed files with 224 additions and 24 deletions

View File

@@ -45,7 +45,7 @@ def make_entity_state_trigger_required_features(
"""Trigger for entity state changes.""" """Trigger for entity state changes."""
_domain = domain _domain = domain
_to_state = to_state _to_states = {to_state}
_required_features = required_features _required_features = required_features
return CustomTrigger return CustomTrigger

View File

@@ -47,7 +47,7 @@ def make_binary_sensor_trigger(
"""Trigger for entity state changes.""" """Trigger for entity state changes."""
_device_class = device_class _device_class = device_class
_to_state = to_state _to_states = {to_state}
return CustomTrigger return CustomTrigger

View File

@@ -98,6 +98,9 @@
} }
}, },
"triggers": { "triggers": {
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},
"started_cooling": { "started_cooling": {
"trigger": "mdi:snowflake" "trigger": "mdi:snowflake"
}, },

View File

@@ -298,6 +298,20 @@
}, },
"title": "Climate", "title": "Climate",
"triggers": { "triggers": {
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to trigger on.",
"name": "Modes"
}
},
"name": "Climate-control device mode changed"
},
"started_cooling": { "started_cooling": {
"description": "Triggers after one or more climate-control devices start cooling.", "description": "Triggers after one or more climate-control devices start cooling.",
"fields": { "fields": {

View File

@@ -1,8 +1,15 @@
"""Provides triggers for climates.""" """Provides triggers for climates."""
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import ( from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger, Trigger,
TriggerConfig,
make_entity_target_state_attribute_trigger, make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger, make_entity_target_state_trigger,
make_entity_transition_trigger, make_entity_transition_trigger,
@@ -10,7 +17,33 @@ from homeassistant.helpers.trigger import (
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [HVACMode]
),
},
}
)
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = DOMAIN
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
self._to_states = set(self._options[CONF_HVAC_MODE])
TRIGGERS: dict[str, type[Trigger]] = { TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger( "started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
), ),

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common .trigger_common: &trigger_common
target: target: &trigger_climate_target
entity: entity:
domain: climate domain: climate
fields: fields:
behavior: behavior: &trigger_behavior
required: true required: true
default: any default: any
selector: selector:
@@ -19,3 +19,18 @@ started_drying: *trigger_common
started_heating: *trigger_common started_heating: *trigger_common
turned_off: *trigger_common turned_off: *trigger_common
turned_on: *trigger_common turned_on: *trigger_common
hvac_mode_changed:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
hvac_mode:
context:
filter_target: target
required: true
selector:
state:
# Note: This should allow selecting multiple modes, but state selector does not support that yet.
hide_states:
- unavailable
- unknown

View File

@@ -433,11 +433,21 @@ class EntityTriggerBase(Trigger):
class EntityTargetStateTriggerBase(EntityTriggerBase): class EntityTargetStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes to a specific state.""" """Trigger for entity state changes to a specific state."""
_to_state: str _to_states: set[str]
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return (
from_state.state != to_state.state
and from_state.state not in self._to_states
)
def is_valid_state(self, state: State) -> bool: def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state.""" """Check if the new state matches the expected state."""
return state.state == self._to_state return state.state in self._to_states
class EntityTransitionTriggerBase(EntityTriggerBase): class EntityTransitionTriggerBase(EntityTriggerBase):
@@ -495,15 +505,20 @@ class EntityTargetStateAttributeTriggerBase(EntityTriggerBase):
def make_entity_target_state_trigger( def make_entity_target_state_trigger(
domain: str, to_state: str domain: str, to_states: str | set[str]
) -> type[EntityTargetStateTriggerBase]: ) -> type[EntityTargetStateTriggerBase]:
"""Create a trigger for entity state changes to a specific state.""" """Create a trigger for entity state changes to specific state(s)."""
if isinstance(to_states, str):
to_states_set = {to_states}
else:
to_states_set = to_states
class CustomTrigger(EntityTargetStateTriggerBase): class CustomTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes.""" """Trigger for entity state changes."""
_domain = domain _domain = domain
_to_state = to_state _to_states = to_states_set
return CustomTrigger return CustomTrigger

View File

@@ -1,5 +1,6 @@
"""The tests for components.""" """The tests for components."""
from collections.abc import Iterable
from enum import StrEnum from enum import StrEnum
import itertools import itertools
from typing import TypedDict from typing import TypedDict
@@ -345,6 +346,15 @@ def set_or_remove_state(
) )
def other_states(state: StrEnum) -> list[str]: def other_states(state: StrEnum | Iterable[StrEnum]) -> list[str]:
"""Return a sorted list with all states except the specified one.""" """Return a sorted list with all states except the specified one."""
return sorted({s.value for s in state.__class__} - {state.value}) if isinstance(state, StrEnum):
excluded_values = {state.value}
enum_class = state.__class__
else:
if len(state) == 0:
raise ValueError("state iterable must not be empty")
excluded_values = {s.value for s in state}
enum_class = list(state)[0].__class__
return sorted({s.value for s in enum_class} - excluded_values)

View File

@@ -1,17 +1,22 @@
"""Test climate trigger.""" """Test climate trigger."""
from collections.abc import Generator from collections.abc import Generator
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from typing import Any
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
import voluptuous as vol
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
ATTR_HVAC_ACTION, ATTR_HVAC_ACTION,
HVACAction, HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID from homeassistant.components.climate.trigger import CONF_HVAC_MODE
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import async_validate_trigger_config
from tests.components import ( from tests.components import (
StateDescription, StateDescription,
@@ -48,6 +53,7 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"trigger_key", "trigger_key",
[ [
"climate.hvac_mode_changed",
"climate.turned_off", "climate.turned_off",
"climate.turned_on", "climate.turned_on",
"climate.started_heating", "climate.started_heating",
@@ -66,20 +72,105 @@ async def test_climate_triggers_gated_by_labs_flag(
) in caplog.text ) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger", "trigger_options", "expected_result"),
[
# Test validating climate.hvac_mode_changed
# Valid configurations
(
"climate.hvac_mode_changed",
{CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
does_not_raise(),
),
(
"climate.hvac_mode_changed",
{CONF_HVAC_MODE: HVACMode.HEAT},
does_not_raise(),
),
# Invalid configurations
(
"climate.hvac_mode_changed",
# Empty hvac_mode list
{CONF_HVAC_MODE: []},
pytest.raises(vol.Invalid),
),
(
"climate.hvac_mode_changed",
# Missing CONF_HVAC_MODE
{},
pytest.raises(vol.Invalid),
),
(
"climate.hvac_mode_changed",
{CONF_HVAC_MODE: ["invalid_mode"]},
pytest.raises(vol.Invalid),
),
],
)
async def test_climate_trigger_validation(
hass: HomeAssistant,
trigger: str,
trigger_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test climate trigger config validation."""
with expected_result:
await async_validate_trigger_config(
hass,
[
{
"platform": trigger,
CONF_TARGET: {CONF_ENTITY_ID: "climate.test_climate"},
CONF_OPTIONS: trigger_options,
}
],
)
def parametrize_climate_trigger_states(
*,
trigger: str,
trigger_options: dict | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
additional_attributes: dict | None = None,
trigger_from_none: bool = True,
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts."""
trigger_options = trigger_options or {}
return [
(s[0], trigger_options, *s[1:])
for s in parametrize_trigger_states(
trigger=trigger,
target_states=target_states,
other_states=other_states,
additional_attributes=additional_attributes,
trigger_from_none=trigger_from_none,
)
]
@pytest.mark.usefixtures("enable_experimental_triggers_conditions") @pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"), ("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"), parametrize_target_entities("climate"),
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("trigger", "states"), ("trigger", "trigger_options", "states"),
[ [
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.hvac_mode_changed",
trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=other_states([HVACMode.HEAT, HVACMode.COOL]),
),
*parametrize_climate_trigger_states(
trigger="climate.turned_off", trigger="climate.turned_off",
target_states=[HVACMode.OFF], target_states=[HVACMode.OFF],
other_states=other_states(HVACMode.OFF), other_states=other_states(HVACMode.OFF),
), ),
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.turned_on", trigger="climate.turned_on",
target_states=[ target_states=[
HVACMode.AUTO, HVACMode.AUTO,
@@ -103,6 +194,7 @@ async def test_climate_state_trigger_behavior_any(
entity_id: str, entity_id: str,
entities_in_target: int, entities_in_target: int,
trigger: str, trigger: str,
trigger_options: dict[str, Any],
states: list[StateDescription], states: list[StateDescription],
) -> None: ) -> None:
"""Test that the climate state trigger fires when any climate state changes to a specific state.""" """Test that the climate state trigger fires when any climate state changes to a specific state."""
@@ -113,7 +205,7 @@ async def test_climate_state_trigger_behavior_any(
set_or_remove_state(hass, eid, states[0]["included"]) set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done() await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config) await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
for state in states[1:]: for state in states[1:]:
included_state = state["included"] included_state = state["included"]
@@ -200,14 +292,20 @@ async def test_climate_state_attribute_trigger_behavior_any(
parametrize_target_entities("climate"), parametrize_target_entities("climate"),
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("trigger", "states"), ("trigger", "trigger_options", "states"),
[ [
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.hvac_mode_changed",
trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=other_states([HVACMode.HEAT, HVACMode.COOL]),
),
*parametrize_climate_trigger_states(
trigger="climate.turned_off", trigger="climate.turned_off",
target_states=[HVACMode.OFF], target_states=[HVACMode.OFF],
other_states=other_states(HVACMode.OFF), other_states=other_states(HVACMode.OFF),
), ),
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.turned_on", trigger="climate.turned_on",
target_states=[ target_states=[
HVACMode.AUTO, HVACMode.AUTO,
@@ -231,6 +329,7 @@ async def test_climate_state_trigger_behavior_first(
entities_in_target: int, entities_in_target: int,
entity_id: str, entity_id: str,
trigger: str, trigger: str,
trigger_options: dict[str, Any],
states: list[StateDescription], states: list[StateDescription],
) -> None: ) -> None:
"""Test that the climate state trigger fires when the first climate changes to a specific state.""" """Test that the climate state trigger fires when the first climate changes to a specific state."""
@@ -241,7 +340,9 @@ async def test_climate_state_trigger_behavior_first(
set_or_remove_state(hass, eid, states[0]["included"]) set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done() await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) await arm_trigger(
hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config
)
for state in states[1:]: for state in states[1:]:
included_state = state["included"] included_state = state["included"]
@@ -326,14 +427,20 @@ async def test_climate_state_attribute_trigger_behavior_first(
parametrize_target_entities("climate"), parametrize_target_entities("climate"),
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("trigger", "states"), ("trigger", "trigger_options", "states"),
[ [
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.hvac_mode_changed",
trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=other_states([HVACMode.HEAT, HVACMode.COOL]),
),
*parametrize_climate_trigger_states(
trigger="climate.turned_off", trigger="climate.turned_off",
target_states=[HVACMode.OFF], target_states=[HVACMode.OFF],
other_states=other_states(HVACMode.OFF), other_states=other_states(HVACMode.OFF),
), ),
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.turned_on", trigger="climate.turned_on",
target_states=[ target_states=[
HVACMode.AUTO, HVACMode.AUTO,
@@ -357,6 +464,7 @@ async def test_climate_state_trigger_behavior_last(
entities_in_target: int, entities_in_target: int,
entity_id: str, entity_id: str,
trigger: str, trigger: str,
trigger_options: dict[str, Any],
states: list[StateDescription], states: list[StateDescription],
) -> None: ) -> None:
"""Test that the climate state trigger fires when the last climate changes to a specific state.""" """Test that the climate state trigger fires when the last climate changes to a specific state."""
@@ -367,7 +475,9 @@ async def test_climate_state_trigger_behavior_last(
set_or_remove_state(hass, eid, states[0]["included"]) set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done() await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) await arm_trigger(
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
)
for state in states[1:]: for state in states[1:]:
included_state = state["included"] included_state = state["included"]