Compare commits

...

10 Commits

Author SHA1 Message Date
Erik Montnemery
4251ea0587 Merge branch 'dev' into scene_triggers 2025-12-19 22:45:44 +01:00
Erik Montnemery
065b0eb5b2 Fix siren entity triggers (#159474) 2025-12-19 22:45:32 +01:00
Michael
6a1d86d5db Add domain driven triggers to lock platform (#159327)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:34:33 +01:00
Petro31
f99a73ef28 Modernize template weather platform and add config flow (#156399) 2025-12-19 22:28:26 +01:00
Erik Montnemery
e9305c3846 Merge branch 'dev' into scene_triggers 2025-12-19 22:19:30 +01:00
Michael
0436d30062 Add turned off and turned on triggers to siren platform (#158847)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:15:06 +01:00
Erik Montnemery
24b6b5452b Add trigger climate.target_temperature_crossed_threshold (#159461)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-19 21:57:10 +01:00
Erik Montnemery
8b91ebfe30 Add test of error handling in numerical_attribute_changed triggers (#159469) 2025-12-19 21:40:56 +01:00
Abílio Costa
937ccc7867 Merge branch 'dev' into scene_triggers 2025-12-16 18:50:53 +00:00
abmantis
385ab8f5d3 Add scene activated trigger 2025-12-16 18:45:56 +00:00
29 changed files with 2337 additions and 477 deletions

View File

@@ -134,7 +134,10 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"scene",
"siren",
"switch",
"text",
"update",

View File

@@ -113,6 +113,9 @@
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},
"target_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"turned_off": {
"trigger": "mdi:power-off"
},

View File

@@ -204,6 +204,14 @@
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above a value",
"below": "Below a value",
"between": "In a range",
"outside": "Outside a range"
}
}
},
"services": {
@@ -362,6 +370,28 @@
},
"name": "Climate-control device target temperature changed"
},
"target_temperature_crossed_threshold": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target temperature crossed threshold"
},
"turned_off": {
"description": "Triggers after one or more climate-control devices turn off.",
"fields": {

View File

@@ -11,6 +11,7 @@ from homeassistant.helpers.trigger import (
Trigger,
TriggerConfig,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
@@ -54,6 +55,9 @@ TRIGGERS: dict[str, type[Trigger]] = {
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,

View File

@@ -33,6 +33,17 @@
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
started_cooling: *trigger_common
started_drying: *trigger_common
started_heating: *trigger_common
@@ -59,3 +70,11 @@ target_temperature_changed:
fields:
above: *number_or_entity
below: *number_or_entity
target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -22,5 +22,19 @@
"unlock": {
"service": "mdi:lock-open-variant"
}
},
"triggers": {
"jammed": {
"trigger": "mdi:lock-alert"
},
"locked": {
"trigger": "mdi:lock"
},
"opened": {
"trigger": "mdi:lock-open-variant"
},
"unlocked": {
"trigger": "mdi:lock-open-variant"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted locks to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"lock": "Lock {entity_name}",
@@ -50,6 +54,15 @@
"message": "The code for {entity_id} doesn't match pattern {code_format}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"lock": {
"description": "Locks a lock.",
@@ -82,5 +95,47 @@
"name": "Unlock"
}
},
"title": "Lock"
"title": "Lock",
"triggers": {
"jammed": {
"description": "Triggers after one or more locks jam.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock jammed"
},
"locked": {
"description": "Triggers after one or more locks lock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock locked"
},
"opened": {
"description": "Triggers after one or more locks open.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock opened"
},
"unlocked": {
"description": "Triggers after one or more locks unlock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock unlocked"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for locks."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, LockState
TRIGGERS: dict[str, type[Trigger]] = {
"jammed": make_entity_target_state_trigger(DOMAIN, LockState.JAMMED),
"locked": make_entity_target_state_trigger(DOMAIN, LockState.LOCKED),
"opened": make_entity_target_state_trigger(DOMAIN, LockState.OPEN),
"unlocked": make_entity_target_state_trigger(DOMAIN, LockState.UNLOCKED),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for locks."""
return TRIGGERS

View File

@@ -0,0 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: lock
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
jammed: *trigger_common
locked: *trigger_common
opened: *trigger_common
unlocked: *trigger_common

View File

@@ -20,5 +20,10 @@
"turn_on": {
"service": "mdi:power"
}
},
"triggers": {
"activated": {
"trigger": "mdi:palette"
}
}
}

View File

@@ -59,5 +59,11 @@
"name": "Activate"
}
},
"title": "Scene"
"title": "Scene",
"triggers": {
"activated": {
"description": "Triggers when a scene was activated",
"name": "Scene activated"
}
}
}

View File

@@ -0,0 +1,42 @@
"""Provides triggers for scenes."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class SceneActivatedTrigger(EntityTriggerBase):
"""Trigger for scene entity activations."""
_domain = DOMAIN
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the scene is activated
# it would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
"activated": SceneActivatedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for scenes."""
return TRIGGERS

View File

@@ -0,0 +1,4 @@
activated:
target:
entity:
domain: scene

View File

@@ -14,5 +14,13 @@
"turn_on": {
"service": "mdi:bullhorn"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:bullhorn-outline"
},
"turned_on": {
"trigger": "mdi:bullhorn"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted sirens to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::siren::title%]",
@@ -13,6 +17,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"toggle": {
"description": "Toggles the siren on/off.",
@@ -41,5 +54,27 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Siren"
"title": "Siren",
"triggers": {
"turned_off": {
"description": "Triggers after one or more sirens turn off.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::trigger_behavior_description%]",
"name": "[%key:component::siren::common::trigger_behavior_name%]"
}
},
"name": "Siren turned off"
},
"turned_on": {
"description": "Triggers after one or more sirens turn on.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::trigger_behavior_description%]",
"name": "[%key:component::siren::common::trigger_behavior_name%]"
}
},
"name": "Siren turned on"
}
}
}

View File

@@ -0,0 +1,17 @@
"""Provides triggers for sirens."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for sirens."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: siren
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -31,6 +31,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
@@ -132,6 +133,15 @@ from .vacuum import (
SERVICE_STOP,
async_create_preview_vacuum,
)
from .weather import (
CONF_CONDITION,
CONF_FORECAST_DAILY,
CONF_FORECAST_HOURLY,
CONF_HUMIDITY,
CONF_TEMPERATURE as CONF_WEATHER_TEMPERATURE,
CONF_TEMPERATURE_UNIT,
async_create_preview_weather,
)
_SCHEMA_STATE: dict[vol.Marker, Any] = {
vol.Required(CONF_STATE): selector.TemplateSelector(),
@@ -394,6 +404,22 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Optional(SERVICE_LOCATE): selector.ActionSelector(),
}
if domain == Platform.WEATHER:
schema |= {
vol.Required(CONF_CONDITION): selector.TemplateSelector(),
vol.Required(CONF_HUMIDITY): selector.TemplateSelector(),
vol.Required(CONF_WEATHER_TEMPERATURE): selector.TemplateSelector(),
vol.Optional(CONF_TEMPERATURE_UNIT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
mode=selector.SelectSelectorMode.DROPDOWN,
sort=True,
),
),
vol.Optional(CONF_FORECAST_DAILY): selector.TemplateSelector(),
vol.Optional(CONF_FORECAST_HOURLY): selector.TemplateSelector(),
}
schema |= {
vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
vol.Optional(CONF_ADVANCED_OPTIONS): section(
@@ -414,6 +440,15 @@ options_schema = partial(generate_schema, flow_type="options")
config_schema = partial(generate_schema, flow_type="config")
async def _get_forecast_description_place_holders(
handler: SchemaCommonFlowHandler,
) -> dict[str, str]:
return {
"daily_link": "https://www.home-assistant.io/integrations/template/#daily-weather-forecast",
"hourly_link": "https://www.home-assistant.io/integrations/template/#hourly-weather-forecast",
}
async def choose_options_step(options: dict[str, Any]) -> str:
"""Return next step_id for options flow according to template_type."""
return cast(str, options["template_type"])
@@ -511,6 +546,7 @@ TEMPLATE_TYPES = [
Platform.SWITCH,
Platform.UPDATE,
Platform.VACUUM,
Platform.WEATHER,
]
CONFIG_FLOW = {
@@ -589,6 +625,12 @@ CONFIG_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.VACUUM),
),
Platform.WEATHER: SchemaFlowFormStep(
config_schema(Platform.WEATHER),
preview="template",
validate_user_input=validate_user_input(Platform.WEATHER),
description_placeholders=_get_forecast_description_place_holders,
),
}
@@ -668,6 +710,12 @@ OPTIONS_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.VACUUM),
),
Platform.WEATHER: SchemaFlowFormStep(
options_schema(Platform.WEATHER),
preview="template",
validate_user_input=validate_user_input(Platform.WEATHER),
description_placeholders=_get_forecast_description_place_holders,
),
}
CREATE_PREVIEW_ENTITY: dict[
@@ -687,6 +735,7 @@ CREATE_PREVIEW_ENTITY: dict[
Platform.SWITCH: async_create_preview_switch,
Platform.UPDATE: async_create_preview_update,
Platform.VACUUM: async_create_preview_vacuum,
Platform.WEATHER: async_create_preview_weather,
}

View File

@@ -463,7 +463,8 @@
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]",
"update": "[%key:component::update::title%]",
"vacuum": "[%key:component::vacuum::title%]"
"vacuum": "[%key:component::vacuum::title%]",
"weather": "[%key:component::weather::title%]"
},
"title": "Template helper"
},
@@ -507,6 +508,36 @@
}
},
"title": "Template vacuum"
},
"weather": {
"data": {
"condition": "Condition",
"device_id": "[%key:common::config_flow::data::device%]",
"forecast_daily": "Forecast daily",
"forecast_hourly": "Forecast hourly",
"humidity": "Humidity",
"name": "[%key:common::config_flow::data::name%]",
"temperature": "Temperature",
"temperature_unit": "Temperature unit"
},
"data_description": {
"condition": "Defines a template to get the current weather condition",
"device_id": "[%key:component::template::common::device_id_description%]",
"forecast_daily": "Defines a template to get the [daily forecast data]({daily_link})",
"forecast_hourly": "Defines a template to get the [hourly forecast data]({hourly_link})",
"humidity": "Defines a template to get the current humidity",
"temperature": "Defines a template to get the current temperature",
"temperature_unit": "The temperature unit"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "Template weather"
}
}
},
@@ -995,6 +1026,36 @@
}
},
"title": "[%key:component::template::config::step::vacuum::title%]"
},
"weather": {
"data": {
"condition": "[%key:component::template::config::step::weather::data::condition%]",
"device_id": "[%key:common::config_flow::data::device%]",
"forecast_daily": "[%key:component::template::config::step::weather::data::forecast_daily%]",
"forecast_hourly": "[%key:component::template::config::step::weather::data::forecast_hourly%]",
"humidity": "[%key:component::template::config::step::weather::data::humidity%]",
"name": "[%key:common::config_flow::data::name%]",
"temperature": "[%key:component::template::config::step::weather::data::temperature%]",
"temperature_unit": "[%key:component::template::config::step::weather::data::temperature_unit%]"
},
"data_description": {
"condition": "[%key:component::template::config::step::weather::data_description::condition%]",
"device_id": "[%key:component::template::common::device_id_description%]",
"forecast_daily": "[%key:component::template::config::step::weather::data_description::forecast_daily%]",
"forecast_hourly": "[%key:component::template::config::step::weather::data_description::forecast_hourly%]",
"humidity": "[%key:component::template::config::step::weather::data_description::humidity%]",
"temperature": "[%key:component::template::config::step::weather::data_description::temperature%]",
"temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "[%key:component::template::config::step::weather::title%]"
}
}
},

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import asyncio
from collections import defaultdict
from collections.abc import Callable, Coroutine, Iterable
from dataclasses import dataclass, field
from enum import StrEnum
import functools
import inspect
import logging
@@ -639,6 +640,126 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
return True
CONF_LOWER_LIMIT = "lower_limit"
CONF_UPPER_LIMIT = "upper_limit"
CONF_THRESHOLD_TYPE = "threshold_type"
class ThresholdType(StrEnum):
"""Numerical threshold types."""
ABOVE = "above"
BELOW = "below"
BETWEEN = "between"
OUTSIDE = "outside"
def _validate_limits_for_threshold_type(value: dict[str, Any]) -> dict[str, Any]:
"""Validate that the correct limits are provided for the selected threshold type."""
threshold_type = value.get(CONF_THRESHOLD_TYPE)
if threshold_type == ThresholdType.ABOVE:
if CONF_LOWER_LIMIT not in value:
raise vol.Invalid("lower_limit is required for threshold_type 'above'")
elif threshold_type == ThresholdType.BELOW:
if CONF_UPPER_LIMIT not in value:
raise vol.Invalid("upper_limit is required for threshold_type 'below'")
elif threshold_type in (ThresholdType.BETWEEN, ThresholdType.OUTSIDE):
if CONF_LOWER_LIMIT not in value or CONF_UPPER_LIMIT not in value:
raise vol.Invalid(
"Both lower_limit and upper_limit are required for"
f" threshold_type '{threshold_type}'"
)
return value
NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
vol.Optional(CONF_LOWER_LIMIT): _number_or_entity,
vol.Optional(CONF_UPPER_LIMIT): _number_or_entity,
vol.Required(CONF_THRESHOLD_TYPE): ThresholdType,
},
_validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT),
_validate_limits_for_threshold_type,
)
}
)
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase):
"""Trigger for numerical state attribute changes.
This trigger only fires when the observed attribute changes from not within to within
the defined threshold.
"""
_attribute: str
_schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
_lower_limit: float | str | None = None
_upper_limit: float | str | None = None
_threshold_type: ThresholdType
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
self._lower_limit = self._options.get(CONF_LOWER_LIMIT)
self._upper_limit = self._options.get(CONF_UPPER_LIMIT)
self._threshold_type = self._options[CONF_THRESHOLD_TYPE]
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 not self.is_valid_state(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check if the new state attribute matches the expected one."""
if self._lower_limit is not None:
if (
lower_limit := _get_numerical_value(self._hass, self._lower_limit)
) is None:
# Entity not found or invalid number, don't trigger
return False
if self._upper_limit is not None:
if (
upper_limit := _get_numerical_value(self._hass, self._upper_limit)
) is None:
# Entity not found or invalid number, don't trigger
return False
# 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
# Note: We do not need to check for lower_limit/upper_limit being None here
# because of the validation done in the schema.
if self._threshold_type == ThresholdType.ABOVE:
return current_value > lower_limit # type: ignore[operator]
if self._threshold_type == ThresholdType.BELOW:
return current_value < upper_limit # type: ignore[operator]
# Mode is BETWEEN or OUTSIDE
between = lower_limit < current_value < upper_limit # type: ignore[operator]
if self._threshold_type == ThresholdType.BETWEEN:
return between
return not between
def make_entity_target_state_trigger(
domain: str, to_states: str | set[str]
) -> type[EntityTargetStateTriggerBase]:
@@ -701,6 +822,20 @@ def make_entity_numerical_state_attribute_changed_trigger(
return CustomTrigger
def make_entity_numerical_state_attribute_crossed_threshold_trigger(
domain: str, attribute: str
) -> type[EntityNumericalStateAttributeCrossedThresholdTriggerBase]:
"""Create a trigger for numerical state attribute change."""
class CustomTrigger(EntityNumericalStateAttributeCrossedThresholdTriggerBase):
"""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

@@ -24,7 +24,13 @@ from homeassistant.const import (
CONF_TARGET,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import async_validate_trigger_config
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UPPER_LIMIT,
ThresholdType,
async_validate_trigger_config,
)
from tests.components import (
StateDescription,
@@ -63,6 +69,7 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
[
"climate.hvac_mode_changed",
"climate.target_temperature_changed",
"climate.target_temperature_crossed_threshold",
"climate.turned_off",
"climate.turned_on",
"climate.started_heating",
@@ -162,6 +169,123 @@ def parametrize_climate_trigger_states(
]
def parametrize_xxx_changed_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_changed triggers."""
return [
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[(HVACMode.AUTO, {attribute: None})],
retrigger_on_target_state=True,
),
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=[
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 0}),
],
retrigger_on_target_state=True,
),
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 50}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 100}),
],
retrigger_on_target_state=True,
),
]
def parametrize_xxx_crossed_threshold_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
return [
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 60}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 100}),
],
),
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 60}),
],
),
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
},
target_states=[
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 0}),
],
),
*parametrize_climate_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 50}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 100}),
],
),
]
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
@@ -243,42 +367,11 @@ async def test_climate_state_trigger_behavior_any(
@pytest.mark.parametrize(
("trigger", "trigger_options", "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_xxx_changed_trigger_states(
"climate.target_temperature_changed", ATTR_TEMPERATURE
),
*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_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
),
*parametrize_climate_trigger_states(
trigger="climate.started_cooling",
@@ -415,19 +508,22 @@ async def test_climate_state_trigger_behavior_first(
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
),
*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})],
@@ -442,6 +538,7 @@ async def test_climate_state_attribute_trigger_behavior_first(
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[tuple[tuple[str, dict], int]],
) -> None:
"""Test that the climate state trigger fires when the first climate state changes to a specific state."""
@@ -452,7 +549,9 @@ async def test_climate_state_attribute_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"]
@@ -549,19 +648,22 @@ async def test_climate_state_trigger_behavior_last(
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
),
*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})],
@@ -576,6 +678,7 @@ async def test_climate_state_attribute_trigger_behavior_last(
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[tuple[tuple[str, dict], int]],
) -> None:
"""Test that the climate state trigger fires when the last climate state changes to a specific state."""
@@ -586,7 +689,9 @@ async def test_climate_state_attribute_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"]

View File

@@ -0,0 +1,261 @@
"""Test lock triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.lock import DOMAIN, LockState
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_locks(hass: HomeAssistant) -> list[str]:
"""Create multiple lock entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"lock.jammed",
"lock.locked",
"lock.opened",
"lock.unlocked",
],
)
async def test_lock_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the lock triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when any lock state changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other locks also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when the first lock changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
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)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other locks should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when the last lock changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
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)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,192 @@
"""Test scene trigger."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_scenes(hass: HomeAssistant) -> list[str]:
"""Create multiple scene entities associated with different targets."""
return (await target_entities(hass, "scene"))["included"]
@pytest.mark.parametrize("trigger_key", ["scene.activated"])
async def test_scene_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the scene triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("scene"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
(
"scene.activated",
[
{"included": {"state": None, "attributes": {}}, "count": 0},
{
"included": {
"state": "2021-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 0,
},
{
"included": {
"state": "2022-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 1,
},
],
),
(
"scene.activated",
[
{"included": {"state": "foo", "attributes": {}}, "count": 0},
{
"included": {
"state": "2021-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 1,
},
{
"included": {
"state": "2022-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 1,
},
],
),
(
"scene.activated",
[
{
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
"count": 0,
},
{
"included": {
"state": "2021-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 0,
},
{
"included": {
"state": "2022-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 1,
},
{
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
"count": 0,
},
],
),
(
"scene.activated",
[
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
{
"included": {
"state": "2021-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 1,
},
{
"included": {
"state": "2022-01-01T23:59:59+00:00",
"attributes": {},
},
"count": 1,
},
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
],
),
],
)
async def test_scene_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_scenes: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the scene state trigger fires when any scene state changes to a specific state."""
other_entity_ids = set(target_scenes) - {entity_id}
# Set all scenes, including the tested scene, to the initial state
for eid in target_scenes:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, None, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other scenes also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()

View File

@@ -0,0 +1,228 @@
"""Test siren triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.siren import DOMAIN
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_sirens(hass: HomeAssistant) -> list[str]:
"""Create multiple siren entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"siren.turned_off",
"siren.turned_on",
],
)
async def test_siren_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the siren triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="siren.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="siren.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_siren_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sirens: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the siren state trigger fires when any siren state changes to a specific state."""
other_entity_ids = set(target_sirens) - {entity_id}
# Set all sirens, including the tested one, to the initial state
for eid in target_sirens:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other sirens also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="siren.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="siren.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_siren_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sirens: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the siren state trigger fires when the first siren changes to a specific state."""
other_entity_ids = set(target_sirens) - {entity_id}
# Set all sirens, including the tested one, to the initial state
for eid in target_sirens:
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)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other sirens should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="siren.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="siren.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_siren_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sirens: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the siren state trigger fires when the last siren changes to a specific state."""
other_entity_ids = set(target_sirens) - {entity_id}
# Set all sirens, including the tested one, to the initial state
for eid in target_sirens:
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)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -53,6 +53,29 @@
'last_wind_speed': None,
})
# ---
# name: test_setup_config_entry
StateSnapshot({
'attributes': ReadOnlyDict({
'assumed_state': True,
'attribution': 'Powered by Home Assistant',
'friendly_name': 'My template',
'humidity': 50,
'precipitation_unit': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'supported_features': 0,
'temperature': 20.0,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_speed_unit': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'weather.my_template',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_trigger_weather_services[config0-1-template-get_forecasts]
dict({
'weather.test': dict({

View File

@@ -270,6 +270,16 @@ BINARY_SENSOR_OPTIONS = {
{"start": []},
{},
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
"sunny",
{"one": "sunny", "two": "cloudy"},
{},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{},
),
],
)
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
@@ -463,6 +473,12 @@ async def test_config_flow(
{"start": []},
{"start": []},
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
),
],
)
async def test_config_flow_device(
@@ -752,6 +768,16 @@ async def test_config_flow_device(
{"start": []},
"state",
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
{"condition": "{{ states('weather.two') }}"},
["sunny", "cloudy"],
{"one": "sunny", "two": "cloudy"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
"condition",
),
],
)
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
@@ -1601,6 +1627,12 @@ async def test_option_flow_sensor_preview_config_entry_removed(
{"start": []},
{"start": []},
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
),
],
)
async def test_options_flow_change_device(

View File

@@ -37,13 +37,15 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import ConfigurationStyle
from .conftest import ConfigurationStyle, async_get_flow_preview_state
from tests.common import (
MockConfigEntry,
assert_setup_component,
async_mock_restore_state_shutdown_restart,
mock_restore_cache_with_extra_data,
)
from tests.typing import WebSocketGenerator
ATTR_FORECAST = "forecast"
@@ -122,6 +124,27 @@ async def setup_weather(
)
@pytest.fixture
async def setup_weather_single_attribute(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
attribute: str,
attribute_template: str,
weather_config: dict[str, Any],
) -> None:
"""Do setup of weather integration."""
extra = {attribute: attribute_template}
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": TEST_OBJECT_ID, **weather_config, **extra}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": TEST_OBJECT_ID, **weather_config, **extra}
)
@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)])
@pytest.mark.parametrize(
"config",
@@ -1129,3 +1152,58 @@ async def test_templated_optional_config(
state = hass.states.get(TEST_WEATHER)
assert state.attributes[attribute] == expected
async def test_setup_config_entry(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
"""Tests creating a weather from a config entry."""
hass.states.async_set(
"weather.test_state",
"sunny",
{},
)
template_config_entry = MockConfigEntry(
data={},
domain=template.DOMAIN,
options={
"name": "My template",
"condition": "{{ states('sensor.test_sensor') }}",
"humidity": "{{ 50 }}",
"temperature": "{{ 20 }}",
"template_type": WEATHER_DOMAIN,
},
title="My template",
)
template_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(template_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("weather.my_template")
assert state is not None
assert state == snapshot
async def test_flow_preview(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the config flow preview."""
state = await async_get_flow_preview_state(
hass,
hass_ws_client,
WEATHER_DOMAIN,
{
"name": "My template",
"condition": "{{ 'sunny' }}",
"humidity": "{{ 50 }}",
"temperature": "{{ 20 }}",
},
)
assert state["state"] == "sunny"

View File

@@ -9,6 +9,7 @@ import pytest
from pytest_unordered import unordered
import voluptuous as vol
from homeassistant.components import automation
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
@@ -18,7 +19,10 @@ from homeassistant.const import (
CONF_BELOW,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TARGET,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -31,14 +35,19 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, trigger
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UPPER_LIMIT,
DATA_PLUGGABLE_ACTIONS,
PluggableAction,
ThresholdType,
Trigger,
TriggerActionRunner,
_async_get_trigger_platform,
async_initialize_triggers,
async_validate_trigger_config,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_integration
@@ -1247,3 +1256,312 @@ async def test_numerical_state_attribute_changed_trigger_config_validation(
}
],
)
async def test_numerical_state_attribute_changed_error_handling(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test numerical state attribute change error handling."""
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"attribute_changed": 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))
hass.states.async_set("test.test_entity", "on", {"test_attribute": 20})
options = {
CONF_OPTIONS: {CONF_ABOVE: "sensor.above", CONF_BELOW: "sensor.below"},
}
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.attribute_changed",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
}
| options,
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
# Test the trigger works
hass.states.async_set("sensor.above", "10")
hass.states.async_set("sensor.below", "90")
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Test the trigger fires again when still within limits
hass.states.async_set("test.test_entity", "on", {"test_attribute": 51})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Test the trigger does not fire when the from-state is unknown or unavailable
for from_state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
hass.states.async_set("test.test_entity", from_state)
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the attribute value is outside the limits
for value in (5, 95):
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the attribute value is missing
hass.states.async_set("test.test_entity", "on", {})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the attribute value is invalid
for value in ("cat", None):
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the above sensor does not exist
hass.states.async_remove("sensor.above")
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the above sensor state is not numeric
for invalid_value in ("cat", None):
hass.states.async_set("sensor.above", invalid_value)
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Reset the above sensor state to a valid numeric value
hass.states.async_set("sensor.above", "10")
# Test the trigger does not fire when the below sensor does not exist
hass.states.async_remove("sensor.below")
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the below sensor state is not numeric
for invalid_value in ("cat", None):
hass.states.async_set("sensor.below", invalid_value)
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_options", "expected_result"),
[
# Valid configurations
(
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: 10},
does_not_raise(),
),
(
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: "sensor.test"},
does_not_raise(),
),
(
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: 90},
does_not_raise(),
),
(
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: "sensor.test"},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: "sensor.test",
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: "sensor.test",
CONF_UPPER_LIMIT: 90,
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: "sensor.test",
CONF_UPPER_LIMIT: "sensor.test",
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: "sensor.test",
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: "sensor.test",
CONF_UPPER_LIMIT: 90,
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: "sensor.test",
CONF_UPPER_LIMIT: "sensor.test",
},
does_not_raise(),
),
# Test verbose choose selector options
# Test invalid configurations
(
# Missing threshold type
{},
pytest.raises(vol.Invalid),
),
(
# Invalid threshold type
{CONF_THRESHOLD_TYPE: "cat"},
pytest.raises(vol.Invalid),
),
(
# Must provide lower limit for ABOVE
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE},
pytest.raises(vol.Invalid),
),
(
# Must provide lower limit for ABOVE
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_UPPER_LIMIT: 90},
pytest.raises(vol.Invalid),
),
(
# Must provide upper limit for BELOW
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW},
pytest.raises(vol.Invalid),
),
(
# Must provide upper limit for BELOW
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_LOWER_LIMIT: 10},
pytest.raises(vol.Invalid),
),
(
# Must provide upper and lower limits for BETWEEN
{CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN},
pytest.raises(vol.Invalid),
),
(
# Must provide upper and lower limits for BETWEEN
{CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_LOWER_LIMIT: 10},
pytest.raises(vol.Invalid),
),
(
# Must provide upper and lower limits for BETWEEN
{CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_UPPER_LIMIT: 90},
pytest.raises(vol.Invalid),
),
(
# Must provide upper and lower limits for OUTSIDE
{CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE},
pytest.raises(vol.Invalid),
),
(
# Must provide upper and lower limits for OUTSIDE
{CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_LOWER_LIMIT: 10},
pytest.raises(vol.Invalid),
),
(
# Must provide upper and lower limits for OUTSIDE
{CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_UPPER_LIMIT: 90},
pytest.raises(vol.Invalid),
),
(
# Must be valid entity id
{
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_ABOVE: "cat",
CONF_BELOW: "dog",
},
pytest.raises(vol.Invalid),
),
(
# Above must be smaller than below
{
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_ABOVE: 90,
CONF_BELOW: 10,
},
pytest.raises(vol.Invalid),
),
],
)
async def test_numerical_state_attribute_crossed_threshold_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_crossed_threshold_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,
}
],
)