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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user