1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 12:59:34 +00:00

Add trigger climate.target_temperature_changed (#159434)

This commit is contained in:
Erik Montnemery
2025-12-19 18:39:53 +01:00
committed by GitHub
parent d8a468833e
commit 85dfe3a107
8 changed files with 409 additions and 8 deletions

View File

@@ -110,6 +110,9 @@
"started_heating": { "started_heating": {
"trigger": "mdi:fire" "trigger": "mdi:fire"
}, },
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},
"turned_off": { "turned_off": {
"trigger": "mdi:power-off" "trigger": "mdi:power-off"
}, },

View File

@@ -192,6 +192,12 @@
"off": "[%key:common::state::off%]" "off": "[%key:common::state::off%]"
} }
}, },
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": { "trigger_behavior": {
"options": { "options": {
"any": "Any", "any": "Any",
@@ -342,6 +348,20 @@
}, },
"name": "Climate-control device started heating" "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": { "turned_off": {
"description": "Triggers after one or more climate-control devices turn off.", "description": "Triggers after one or more climate-control devices turn off.",
"fields": { "fields": {

View File

@@ -2,7 +2,7 @@
import voluptuous as vol 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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import ( from homeassistant.helpers.trigger import (
@@ -10,6 +10,7 @@ from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase, EntityTargetStateTriggerBase,
Trigger, Trigger,
TriggerConfig, TriggerConfig,
make_entity_numerical_state_attribute_changed_trigger,
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,
@@ -50,6 +51,9 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger( "started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING 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_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger( "turned_on": make_entity_transition_trigger(
DOMAIN, DOMAIN,

View File

@@ -14,6 +14,25 @@
- last - last
- any - 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_cooling: *trigger_common
started_drying: *trigger_common started_drying: *trigger_common
started_heating: *trigger_common started_heating: *trigger_common
@@ -34,3 +53,9 @@ hvac_mode_changed:
- unavailable - unavailable
- unknown - unknown
multiple: true multiple: true
target_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity

View File

@@ -16,7 +16,9 @@ import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
CONF_ABOVE,
CONF_ALIAS, CONF_ALIAS,
CONF_BELOW,
CONF_ENABLED, CONF_ENABLED,
CONF_ID, CONF_ID,
CONF_OPTIONS, CONF_OPTIONS,
@@ -504,6 +506,139 @@ class EntityTargetStateAttributeTriggerBase(EntityTriggerBase):
return state.attributes.get(self._attribute) == self._attribute_to_state 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( def make_entity_target_state_trigger(
domain: str, to_states: str | set[str] domain: str, to_states: str | set[str]
) -> type[EntityTargetStateTriggerBase]: ) -> type[EntityTargetStateTriggerBase]:
@@ -552,6 +687,20 @@ def make_entity_origin_state_trigger(
return CustomTrigger 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( def make_entity_target_state_attribute_trigger(
domain: str, attribute: str, to_state: str domain: str, attribute: str, to_state: str
) -> type[EntityTargetStateAttributeTriggerBase]: ) -> type[EntityTargetStateAttributeTriggerBase]:

View File

@@ -171,6 +171,7 @@ def parametrize_trigger_states(
other_states: list[str | None | tuple[str | None, dict]], other_states: list[str | None | tuple[str | None, dict]],
additional_attributes: dict | None = None, additional_attributes: dict | None = None,
trigger_from_none: bool = True, trigger_from_none: bool = True,
retrigger_on_target_state: bool = False,
) -> list[tuple[str, list[StateDescription]]]: ) -> list[tuple[str, list[StateDescription]]]:
"""Parametrize states and expected service call counts. """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 Set `trigger_from_none` to False if the trigger is not expected to fire
when the initial state is None. 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), Returns a list of tuples with (trigger, list of states),
where states is a list of StateDescription dicts. where states is a list of StateDescription dicts.
""" """
@@ -214,7 +218,7 @@ def parametrize_trigger_states(
"count": count, "count": count,
} }
return [ tests = [
# Initial state None # Initial state None
( (
trigger, trigger,
@@ -260,6 +264,9 @@ def parametrize_trigger_states(
state_with_attributes(target_state, 0), state_with_attributes(target_state, 0),
state_with_attributes(other_state, 0), state_with_attributes(other_state, 0),
state_with_attributes(target_state, 1), 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 target_state in target_states
for other_state in other_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( async def arm_trigger(
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -14,7 +14,15 @@ from homeassistant.components.climate.const import (
HVACMode, HVACMode,
) )
from homeassistant.components.climate.trigger import CONF_HVAC_MODE 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.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.trigger import async_validate_trigger_config
@@ -54,6 +62,7 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
"trigger_key", "trigger_key",
[ [
"climate.hvac_mode_changed", "climate.hvac_mode_changed",
"climate.target_temperature_changed",
"climate.turned_off", "climate.turned_off",
"climate.turned_on", "climate.turned_on",
"climate.started_heating", "climate.started_heating",
@@ -136,6 +145,7 @@ def parametrize_climate_trigger_states(
other_states: list[str | None | tuple[str | None, dict]], other_states: list[str | None | tuple[str | None, dict]],
additional_attributes: dict | None = None, additional_attributes: dict | None = None,
trigger_from_none: bool = True, trigger_from_none: bool = True,
retrigger_on_target_state: bool = False,
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]: ) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts.""" """Parametrize states and expected service call counts."""
trigger_options = trigger_options or {} trigger_options = trigger_options or {}
@@ -147,6 +157,7 @@ def parametrize_climate_trigger_states(
other_states=other_states, other_states=other_states,
additional_attributes=additional_attributes, additional_attributes=additional_attributes,
trigger_from_none=trigger_from_none, 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"), 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.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", trigger="climate.started_cooling",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
), ),
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.started_drying", trigger="climate.started_drying",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
), ),
*parametrize_trigger_states( *parametrize_climate_trigger_states(
trigger="climate.started_heating", trigger="climate.started_heating",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], 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, 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."""
@@ -267,7 +316,7 @@ async def test_climate_state_attribute_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"]

View File

@@ -1,6 +1,8 @@
"""The tests for the trigger helper.""" """The tests for the trigger helper."""
from contextlib import AbstractContextManager, nullcontext as does_not_raise
import io import io
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
import pytest 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.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH
from homeassistant.components.tag import DOMAIN as DOMAIN_TAG from homeassistant.components.tag import DOMAIN as DOMAIN_TAG
from homeassistant.components.text import DOMAIN as DOMAIN_TEXT 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 ( from homeassistant.core import (
CALLBACK_TYPE, CALLBACK_TYPE,
Context, Context,
@@ -29,6 +38,7 @@ from homeassistant.helpers.trigger import (
_async_get_trigger_platform, _async_get_trigger_platform,
async_initialize_triggers, async_initialize_triggers,
async_validate_trigger_config, async_validate_trigger_config,
make_entity_numerical_state_attribute_changed_trigger,
) )
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_integration 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", {}) assert await async_setup_component(hass, "light", {})
await hass.async_block_till_done() await hass.async_block_till_done()
assert trigger_events == [] 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,
}
],
)