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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"hvac_mode_changed": {
|
||||
"trigger": "mdi:thermostat"
|
||||
},
|
||||
"started_cooling": {
|
||||
"trigger": "mdi:snowflake"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user