diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 150f51f8663..07b47215db2 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -110,6 +110,9 @@ "started_heating": { "trigger": "mdi:fire" }, + "target_temperature_changed": { + "trigger": "mdi:thermometer" + }, "turned_off": { "trigger": "mdi:power-off" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index b8c8cb543eb..4d0e44ece3a 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -192,6 +192,12 @@ "off": "[%key:common::state::off%]" } }, + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, "trigger_behavior": { "options": { "any": "Any", @@ -342,6 +348,20 @@ }, "name": "Climate-control device started heating" }, + "target_temperature_changed": { + "description": "Triggers after the temperature setpoint of one or more climate-control devices changes.", + "fields": { + "above": { + "description": "Trigger when the target temperature is above this value.", + "name": "Above" + }, + "below": { + "description": "Trigger when the target temperature is below this value.", + "name": "Below" + } + }, + "name": "Climate-control device target temperature changed" + }, "turned_off": { "description": "Triggers after one or more climate-control devices turn off.", "fields": { diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index e4cd9ea1794..55bed27d621 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant.const import CONF_OPTIONS +from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import ( @@ -10,6 +10,7 @@ from homeassistant.helpers.trigger import ( EntityTargetStateTriggerBase, Trigger, TriggerConfig, + make_entity_numerical_state_attribute_changed_trigger, make_entity_target_state_attribute_trigger, make_entity_target_state_trigger, make_entity_transition_trigger, @@ -50,6 +51,9 @@ TRIGGERS: dict[str, type[Trigger]] = { "started_drying": make_entity_target_state_attribute_trigger( DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING ), + "target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger( + DOMAIN, ATTR_TEMPERATURE + ), "turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF), "turned_on": make_entity_transition_trigger( DOMAIN, diff --git a/homeassistant/components/climate/triggers.yaml b/homeassistant/components/climate/triggers.yaml index 8d86187eb86..0242a1351df 100644 --- a/homeassistant/components/climate/triggers.yaml +++ b/homeassistant/components/climate/triggers.yaml @@ -14,6 +14,25 @@ - last - any +.number_or_entity: &number_or_entity + required: false + selector: + choose: + choices: + entity: + selector: + entity: + filter: + domain: + - input_number + - number + - sensor + number: + selector: + number: + mode: box + translation_key: number_or_entity + started_cooling: *trigger_common started_drying: *trigger_common started_heating: *trigger_common @@ -34,3 +53,9 @@ hvac_mode_changed: - unavailable - unknown multiple: true + +target_temperature_changed: + target: *trigger_climate_target + fields: + above: *number_or_entity + below: *number_or_entity diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 828452ba01b..ba308bf89f9 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -16,7 +16,9 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_ABOVE, CONF_ALIAS, + CONF_BELOW, CONF_ENABLED, CONF_ID, CONF_OPTIONS, @@ -504,6 +506,139 @@ class EntityTargetStateAttributeTriggerBase(EntityTriggerBase): return state.attributes.get(self._attribute) == self._attribute_to_state +def _validate_range[_T: dict[str, Any]]( + lower_limit: str, upper_limit: str +) -> Callable[[_T], _T]: + """Generate range validator.""" + + def _validate_range(value: _T) -> _T: + above = value.get(lower_limit) + below = value.get(upper_limit) + + if above is None or below is None: + return value + + if isinstance(above, str) or isinstance(below, str): + return value + + if above > below: + raise vol.Invalid( + ( + f"A value can never be above {above} and below {below} at the same" + " time. You probably want two different triggers." + ), + ) + + return value + + return _validate_range + + +_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema( + { + vol.Required("chosen_selector"): vol.In(["number", "entity"]), + vol.Optional("entity"): cv.entity_id, + vol.Optional("number"): vol.Coerce(float), + } +) + + +def _validate_number_or_entity(value: dict | float | str) -> float | str: + """Validate number or entity selector result.""" + if isinstance(value, dict): + _NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value) + return value[value["chosen_selector"]] # type: ignore[no-any-return] + return value + + +_number_or_entity = vol.All( + _validate_number_or_entity, vol.Any(vol.Coerce(float), cv.entity_id) +) + +NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_OPTIONS): vol.All( + { + vol.Optional(CONF_ABOVE): _number_or_entity, + vol.Optional(CONF_BELOW): _number_or_entity, + }, + _validate_range(CONF_ABOVE, CONF_BELOW), + ) + } +) + + +def _get_numerical_value( + hass: HomeAssistant, entity_or_float: float | str +) -> float | None: + """Get numerical value from float or entity state.""" + if isinstance(entity_or_float, str): + if not (state := hass.states.get(entity_or_float)): + # Entity not found + return None + try: + return float(state.state) + except (TypeError, ValueError): + # Entity state is not a valid number + return None + return entity_or_float + + +class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase): + """Trigger for numerical state attribute changes.""" + + _attribute: str + _schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA + + _above: None | float | str + _below: None | float | str + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the state trigger.""" + super().__init__(hass, config) + self._above = self._options.get(CONF_ABOVE) + self._below = self._options.get(CONF_BELOW) + + 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.attributes.get(self._attribute) != to_state.attributes.get( + self._attribute + ) + + def is_valid_state(self, state: State) -> bool: + """Check if the new state attribute matches the expected one.""" + # Handle missing or None attribute case first to avoid expensive exceptions + if (_attribute_value := state.attributes.get(self._attribute)) is None: + return False + + try: + current_value = float(_attribute_value) + except (TypeError, ValueError): + # Attribute is not a valid number, don't trigger + return False + + if self._above is not None: + if (above := _get_numerical_value(self._hass, self._above)) is None: + # Entity not found or invalid number, don't trigger + return False + if current_value <= above: + # The number is not above the limit, don't trigger + return False + + if self._below is not None: + if (below := _get_numerical_value(self._hass, self._below)) is None: + # Entity not found or invalid number, don't trigger + return False + if current_value >= below: + # The number is not below the limit, don't trigger + return False + + return True + + def make_entity_target_state_trigger( domain: str, to_states: str | set[str] ) -> type[EntityTargetStateTriggerBase]: @@ -552,6 +687,20 @@ def make_entity_origin_state_trigger( return CustomTrigger +def make_entity_numerical_state_attribute_changed_trigger( + domain: str, attribute: str +) -> type[EntityNumericalStateAttributeChangedTriggerBase]: + """Create a trigger for numerical state attribute change.""" + + class CustomTrigger(EntityNumericalStateAttributeChangedTriggerBase): + """Trigger for numerical state attribute changes.""" + + _domain = domain + _attribute = attribute + + return CustomTrigger + + def make_entity_target_state_attribute_trigger( domain: str, attribute: str, to_state: str ) -> type[EntityTargetStateAttributeTriggerBase]: diff --git a/tests/components/__init__.py b/tests/components/__init__.py index b7e5d9913b3..59ca6c02592 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -171,6 +171,7 @@ def parametrize_trigger_states( other_states: list[str | None | tuple[str | None, dict]], additional_attributes: dict | None = None, trigger_from_none: bool = True, + retrigger_on_target_state: bool = False, ) -> list[tuple[str, list[StateDescription]]]: """Parametrize states and expected service call counts. @@ -180,6 +181,9 @@ def parametrize_trigger_states( Set `trigger_from_none` to False if the trigger is not expected to fire when the initial state is None. + Set `retrigger_on_target_state` to True if the trigger is expected to fire + when the state changes to another target state. + Returns a list of tuples with (trigger, list of states), where states is a list of StateDescription dicts. """ @@ -214,7 +218,7 @@ def parametrize_trigger_states( "count": count, } - return [ + tests = [ # Initial state None ( trigger, @@ -260,6 +264,9 @@ def parametrize_trigger_states( state_with_attributes(target_state, 0), state_with_attributes(other_state, 0), state_with_attributes(target_state, 1), + # Repeat target state to test retriggering + state_with_attributes(target_state, 0), + state_with_attributes(STATE_UNAVAILABLE, 0), ) for target_state in target_states for other_state in other_states @@ -299,6 +306,34 @@ def parametrize_trigger_states( ), ] + if len(target_states) > 1: + # If more than one target state, test state change between target states + tests.append( + ( + trigger, + list( + itertools.chain.from_iterable( + ( + state_with_attributes(target_states[idx - 1], 0), + state_with_attributes( + target_state, 1 if retrigger_on_target_state else 0 + ), + state_with_attributes(other_state, 0), + state_with_attributes(target_states[idx - 1], 1), + state_with_attributes( + target_state, 1 if retrigger_on_target_state else 0 + ), + state_with_attributes(STATE_UNAVAILABLE, 0), + ) + for idx, target_state in enumerate(target_states[1:], start=1) + for other_state in other_states + ) + ), + ), + ) + + return tests + async def arm_trigger( hass: HomeAssistant, diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 2441a739ec8..8d78a5d5499 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -14,7 +14,15 @@ from homeassistant.components.climate.const import ( HVACMode, ) 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.const import ( + ATTR_LABEL_ID, + ATTR_TEMPERATURE, + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.trigger import async_validate_trigger_config @@ -54,6 +62,7 @@ async def target_climates(hass: HomeAssistant) -> list[str]: "trigger_key", [ "climate.hvac_mode_changed", + "climate.target_temperature_changed", "climate.turned_off", "climate.turned_on", "climate.started_heating", @@ -136,6 +145,7 @@ def parametrize_climate_trigger_states( other_states: list[str | None | tuple[str | None, dict]], additional_attributes: dict | None = None, trigger_from_none: bool = True, + retrigger_on_target_state: bool = False, ) -> list[tuple[str, dict[str, Any], list[StateDescription]]]: """Parametrize states and expected service call counts.""" trigger_options = trigger_options or {} @@ -147,6 +157,7 @@ def parametrize_climate_trigger_states( other_states=other_states, additional_attributes=additional_attributes, trigger_from_none=trigger_from_none, + retrigger_on_target_state=retrigger_on_target_state, ) ] @@ -230,19 +241,56 @@ async def test_climate_state_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.target_temperature_changed", + trigger_options={}, + target_states=[ + (HVACMode.AUTO, {ATTR_TEMPERATURE: 0}), + (HVACMode.AUTO, {ATTR_TEMPERATURE: 50}), + (HVACMode.AUTO, {ATTR_TEMPERATURE: 100}), + ], + other_states=[(HVACMode.AUTO, {ATTR_TEMPERATURE: None})], + retrigger_on_target_state=True, + ), + *parametrize_climate_trigger_states( + trigger="climate.target_temperature_changed", + trigger_options={CONF_ABOVE: 10}, + target_states=[ + (HVACMode.AUTO, {ATTR_TEMPERATURE: 50}), + (HVACMode.AUTO, {ATTR_TEMPERATURE: 100}), + ], + other_states=[ + (HVACMode.AUTO, {ATTR_TEMPERATURE: None}), + (HVACMode.AUTO, {ATTR_TEMPERATURE: 0}), + ], + retrigger_on_target_state=True, + ), + *parametrize_climate_trigger_states( + trigger="climate.target_temperature_changed", + trigger_options={CONF_BELOW: 90}, + target_states=[ + (HVACMode.AUTO, {ATTR_TEMPERATURE: 0}), + (HVACMode.AUTO, {ATTR_TEMPERATURE: 50}), + ], + other_states=[ + (HVACMode.AUTO, {ATTR_TEMPERATURE: None}), + (HVACMode.AUTO, {ATTR_TEMPERATURE: 100}), + ], + retrigger_on_target_state=True, + ), + *parametrize_climate_trigger_states( trigger="climate.started_cooling", target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], ), - *parametrize_trigger_states( + *parametrize_climate_trigger_states( trigger="climate.started_drying", target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], ), - *parametrize_trigger_states( + *parametrize_climate_trigger_states( trigger="climate.started_heating", target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], @@ -257,6 +305,7 @@ async def test_climate_state_attribute_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.""" @@ -267,7 +316,7 @@ async def test_climate_state_attribute_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"] diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 34f0212ee34..ad51b895b37 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,6 +1,8 @@ """The tests for the trigger helper.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise import io +from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest @@ -11,6 +13,13 @@ from homeassistant.components.sun import DOMAIN as DOMAIN_SUN from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH from homeassistant.components.tag import DOMAIN as DOMAIN_TAG from homeassistant.components.text import DOMAIN as DOMAIN_TEXT +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, +) from homeassistant.core import ( CALLBACK_TYPE, Context, @@ -29,6 +38,7 @@ from homeassistant.helpers.trigger import ( _async_get_trigger_platform, async_initialize_triggers, async_validate_trigger_config, + make_entity_numerical_state_attribute_changed_trigger, ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration @@ -1131,3 +1141,109 @@ async def test_subscribe_triggers_no_triggers( assert await async_setup_component(hass, "light", {}) await hass.async_block_till_done() assert trigger_events == [] + + +@pytest.mark.parametrize( + ("trigger_options", "expected_result"), + [ + # Test validating climate.target_temperature_changed + # Valid configurations + ( + {}, + does_not_raise(), + ), + ( + {CONF_ABOVE: 10}, + does_not_raise(), + ), + ( + {CONF_ABOVE: "sensor.test"}, + does_not_raise(), + ), + ( + {CONF_BELOW: 90}, + does_not_raise(), + ), + ( + {CONF_BELOW: "sensor.test"}, + does_not_raise(), + ), + ( + {CONF_ABOVE: 10, CONF_BELOW: 90}, + does_not_raise(), + ), + ( + {CONF_ABOVE: "sensor.test", CONF_BELOW: 90}, + does_not_raise(), + ), + ( + {CONF_ABOVE: 10, CONF_BELOW: "sensor.test"}, + does_not_raise(), + ), + ( + {CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"}, + does_not_raise(), + ), + # Test verbose choose selector options + ( + {CONF_ABOVE: {"chosen_selector": "entity", "entity": "sensor.test"}}, + does_not_raise(), + ), + ( + {CONF_ABOVE: {"chosen_selector": "number", "number": 10}}, + does_not_raise(), + ), + ( + {CONF_BELOW: {"chosen_selector": "entity", "entity": "sensor.test"}}, + does_not_raise(), + ), + ( + {CONF_BELOW: {"chosen_selector": "number", "number": 90}}, + does_not_raise(), + ), + # Test invalid configurations + ( + # Must be valid entity id + {CONF_ABOVE: "cat", CONF_BELOW: "dog"}, + pytest.raises(vol.Invalid), + ), + ( + # Above must be smaller than below + {CONF_ABOVE: 90, CONF_BELOW: 10}, + pytest.raises(vol.Invalid), + ), + ( + # Invalid choose selector option + {CONF_BELOW: {"chosen_selector": "cat", "cat": 90}}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_numerical_state_attribute_changed_trigger_config_validation( + hass: HomeAssistant, + trigger_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test numerical state attribute change trigger config validation.""" + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return { + "test_trigger": make_entity_numerical_state_attribute_changed_trigger( + "test", "test_attribute" + ), + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + with expected_result: + await async_validate_trigger_config( + hass, + [ + { + "platform": "test.test_trigger", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: trigger_options, + } + ], + )