1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +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": {
"trigger": "mdi:fire"
},
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},
"turned_off": {
"trigger": "mdi:power-off"
},

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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

View File

@@ -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]:

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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,
}
],
)