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."""
_domain = domain
_to_state = to_state
_to_states = {to_state}
_required_features = required_features
return CustomTrigger

View File

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

View File

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

View File

@@ -298,6 +298,20 @@
},
"title": "Climate",
"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": {
"description": "Triggers after one or more climate-control devices start cooling.",
"fields": {

View File

@@ -1,8 +1,15 @@
"""Provides triggers for climates."""
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
@@ -10,7 +17,33 @@ from homeassistant.helpers.trigger import (
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]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common
target:
target: &trigger_climate_target
entity:
domain: climate
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
@@ -19,3 +19,18 @@ started_drying: *trigger_common
started_heating: *trigger_common
turned_off: *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):
"""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:
"""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):
@@ -495,15 +505,20 @@ class EntityTargetStateAttributeTriggerBase(EntityTriggerBase):
def make_entity_target_state_trigger(
domain: str, to_state: str
domain: str, to_states: str | set[str]
) -> 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):
"""Trigger for entity state changes."""
_domain = domain
_to_state = to_state
_to_states = to_states_set
return CustomTrigger

View File

@@ -1,5 +1,6 @@
"""The tests for components."""
from collections.abc import Iterable
from enum import StrEnum
import itertools
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 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."""
from collections.abc import Generator
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from typing import Any
from unittest.mock import patch
import pytest
import voluptuous as vol
from homeassistant.components.climate.const import (
ATTR_HVAC_ACTION,
HVACAction,
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.helpers.trigger import async_validate_trigger_config
from tests.components import (
StateDescription,
@@ -48,6 +53,7 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
@pytest.mark.parametrize(
"trigger_key",
[
"climate.hvac_mode_changed",
"climate.turned_off",
"climate.turned_on",
"climate.started_heating",
@@ -66,20 +72,105 @@ async def test_climate_triggers_gated_by_labs_flag(
) 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.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@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",
target_states=[HVACMode.OFF],
other_states=other_states(HVACMode.OFF),
),
*parametrize_trigger_states(
*parametrize_climate_trigger_states(
trigger="climate.turned_on",
target_states=[
HVACMode.AUTO,
@@ -103,6 +194,7 @@ async def test_climate_state_trigger_behavior_any(
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[StateDescription],
) -> None:
"""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"])
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:]:
included_state = state["included"]
@@ -200,14 +292,20 @@ async def test_climate_state_attribute_trigger_behavior_any(
parametrize_target_entities("climate"),
)
@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",
target_states=[HVACMode.OFF],
other_states=other_states(HVACMode.OFF),
),
*parametrize_trigger_states(
*parametrize_climate_trigger_states(
trigger="climate.turned_on",
target_states=[
HVACMode.AUTO,
@@ -231,6 +329,7 @@ async def test_climate_state_trigger_behavior_first(
entities_in_target: int,
entity_id: str,
trigger: str,
trigger_options: dict[str, Any],
states: list[StateDescription],
) -> None:
"""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"])
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:]:
included_state = state["included"]
@@ -326,14 +427,20 @@ async def test_climate_state_attribute_trigger_behavior_first(
parametrize_target_entities("climate"),
)
@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",
target_states=[HVACMode.OFF],
other_states=other_states(HVACMode.OFF),
),
*parametrize_trigger_states(
*parametrize_climate_trigger_states(
trigger="climate.turned_on",
target_states=[
HVACMode.AUTO,
@@ -357,6 +464,7 @@ async def test_climate_state_trigger_behavior_last(
entities_in_target: int,
entity_id: str,
trigger: str,
trigger_options: dict[str, Any],
states: list[StateDescription],
) -> None:
"""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"])
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:]:
included_state = state["included"]