diff --git a/homeassistant/components/alarm_control_panel/trigger.py b/homeassistant/components/alarm_control_panel/trigger.py index d52f2e3cacd..19302900823 100644 --- a/homeassistant/components/alarm_control_panel/trigger.py +++ b/homeassistant/components/alarm_control_panel/trigger.py @@ -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 diff --git a/homeassistant/components/binary_sensor/trigger.py b/homeassistant/components/binary_sensor/trigger.py index b63c1420c98..4dfee30b2c2 100644 --- a/homeassistant/components/binary_sensor/trigger.py +++ b/homeassistant/components/binary_sensor/trigger.py @@ -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 diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 1922c95348c..150f51f8663 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -98,6 +98,9 @@ } }, "triggers": { + "hvac_mode_changed": { + "trigger": "mdi:thermostat" + }, "started_cooling": { "trigger": "mdi:snowflake" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index e813aec7368..b8c8cb543eb 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -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": { diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index db7570030fb..e4cd9ea1794 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -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 ), diff --git a/homeassistant/components/climate/triggers.yaml b/homeassistant/components/climate/triggers.yaml index 5d4f9c18976..62965c3b378 100644 --- a/homeassistant/components/climate/triggers.yaml +++ b/homeassistant/components/climate/triggers.yaml @@ -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 diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index bd0d55a7a8f..828452ba01b 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -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 diff --git a/tests/components/__init__.py b/tests/components/__init__.py index 15d50d342c0..b7e5d9913b3 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -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) diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index b6245b0e659..23c090015f8 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -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"]