Compare commits

...

29 Commits

Author SHA1 Message Date
Claude
0f3c3e5b0d Add media_player.muted trigger 2025-11-11 22:47:54 +00:00
Claude
b46640a1c2 Add media_player.paused trigger 2025-11-11 22:45:33 +00:00
Claude
f7727e8192 Add cover.position_changed trigger
Implements a trigger that fires when a cover's position changes. Supports:
- Monitoring current_position attribute (0-100 range)
- Optional threshold filters: lower, upper, above, below
- Device class filtering (curtain, shutter, blind)
- Provides from_position and to_position in trigger data
2025-11-11 22:44:58 +00:00
Claude
296c41c46c Add climate trigger tests 2025-11-11 22:44:08 +00:00
Claude
a7d5140f80 Add cover.stops trigger
Implements a trigger that fires when a cover stops moving. Triggers
when a cover transitions from "opening" or "closing" state to "open"
or "closed" state. Supports device class filtering (curtain, shutter, blind).
2025-11-11 22:43:23 +00:00
Claude
e8cd2ad1e6 Add climate trigger metadata and translations 2025-11-11 22:42:56 +00:00
Claude
10bd2ffc5f Add cover.closes trigger
Implements a trigger that fires when a cover closes. Supports:
- Triggering on "closing" or "closed" state changes
- Optional fully_closed flag to only trigger at 0% position
- Device class filtering (curtain, shutter, blind)
2025-11-11 22:42:12 +00:00
Claude
b9dac02e8e Add climate.current_humidity_changed trigger 2025-11-11 22:42:03 +00:00
Claude
8605eb046a Add climate.target_humidity_changed trigger 2025-11-11 22:41:28 +00:00
Claude
26b4fa5d39 Add media_player.turns_off trigger 2025-11-11 22:41:16 +00:00
Claude
be23d3d43d Add climate.current_temperature_changed trigger 2025-11-11 22:40:54 +00:00
Claude
a4fbb597f4 Add cover.opens trigger
Implements a trigger that fires when a cover opens. Supports:
- Triggering on "opening" or "open" state changes
- Optional fully_opened flag to only trigger at 100% position
- Device class filtering (curtain, shutter, blind)
2025-11-11 22:40:52 +00:00
Claude
ee11fc37d5 Add media_player.turns_on trigger 2025-11-11 22:40:28 +00:00
Claude
9900e49bcc Add climate.target_temperature_changed trigger 2025-11-11 22:40:21 +00:00
Claude
3feb3fefef Add climate.drying trigger 2025-11-11 22:39:24 +00:00
Claude
b3e4f6dc43 Add climate.heating trigger 2025-11-11 22:38:53 +00:00
Claude
a9ba0bea8f Add climate.cooling trigger 2025-11-11 22:38:24 +00:00
Claude
bafa1e250d Add climate.mode_changed trigger 2025-11-11 22:37:48 +00:00
Claude
734c6f27c6 Add climate.turns_off trigger 2025-11-11 22:37:10 +00:00
Claude
37eed7fae8 Add climate.turns_on trigger 2025-11-11 22:36:42 +00:00
Claude
37aa4c68d9 Add light.brightness_changed trigger 2025-11-11 22:31:16 +00:00
Claude
80f8d94db4 Add light.turns_off trigger 2025-11-11 22:29:17 +00:00
Claude
7c0a7f4f9d Add assist_satellite.idle trigger 2025-11-11 22:28:20 +00:00
Claude
5d5d7f7acf Add light.turns_on trigger 2025-11-11 22:28:02 +00:00
Claude
f0feb93fe1 Add assist_satellite.responding trigger 2025-11-11 22:27:53 +00:00
Claude
9361ccbfc2 Add assist_satellite.processing trigger 2025-11-11 22:27:30 +00:00
Claude
e9c76f1053 Add assist_satellite.listening trigger 2025-11-11 22:27:08 +00:00
Claude
36b100c40a Add alarm_control_panel.disarmed trigger 2025-11-11 22:26:45 +00:00
Claude
60107a1492 Add alarm_control_panel.armed trigger 2025-11-11 22:25:27 +00:00
24 changed files with 6357 additions and 6 deletions

View File

@@ -143,5 +143,28 @@
"name": "Trigger"
}
},
"title": "Alarm control panel"
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description_configured": "Triggers when an alarm is armed",
"fields": {
"mode": {
"description": "The arm modes to trigger on. If empty, triggers on all arm modes.",
"name": "Arm modes"
}
},
"name": "When an alarm is armed"
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description_configured": "Triggers when an alarm is disarmed",
"name": "When an alarm is disarmed"
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description_configured": "Triggers when an alarm is triggered",
"name": "When an alarm is triggered"
}
}
}

View File

@@ -0,0 +1,283 @@
"""Provides triggers for alarm control panels."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, AlarmControlPanelState
CONF_MODE = "mode"
ARMED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_MODE, default=[]): vol.All(
cv.ensure_list,
[
vol.In(
[
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
]
)
],
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
DISARMED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TRIGGERED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class AlarmArmedTrigger(Trigger):
"""Trigger for when an alarm control panel is armed."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, ARMED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm armed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
mode_filter = self._options[CONF_MODE]
# All armed states
armed_states = {
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
}
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is an armed state
if to_state.state not in armed_states:
return
# If mode filter is specified, check if the mode matches
if mode_filter and to_state.state not in mode_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm armed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class AlarmDisarmedTrigger(Trigger):
"""Trigger for when an alarm control panel is disarmed."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, DISARMED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm disarmed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is disarmed
if to_state.state != AlarmControlPanelState.DISARMED:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm disarmed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class AlarmTriggeredTrigger(Trigger):
"""Trigger for when an alarm control panel is triggered."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TRIGGERED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm triggered trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is triggered
if to_state.state != AlarmControlPanelState.TRIGGERED:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm triggered on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"armed": AlarmArmedTrigger,
"disarmed": AlarmDisarmedTrigger,
"triggered": AlarmTriggeredTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for alarm control panels."""
return TRIGGERS

View File

@@ -0,0 +1,30 @@
armed:
target:
entity:
domain: alarm_control_panel
fields:
mode:
required: false
default: []
selector:
select:
multiple: true
options:
- value: armed_home
label: Home
- value: armed_away
label: Away
- value: armed_night
label: Night
- value: armed_vacation
label: Vacation
- value: armed_custom_bypass
label: Custom bypass
disarmed:
target:
entity:
domain: alarm_control_panel
triggered:
target:
entity:
domain: alarm_control_panel

View File

@@ -98,5 +98,27 @@
"name": "Start conversation"
}
},
"title": "Assist satellite"
"title": "Assist satellite",
"triggers": {
"listening": {
"description": "Triggers when a satellite starts listening for a command.",
"description_configured": "Triggers when a satellite starts listening for a command",
"name": "When a satellite starts listening"
},
"processing": {
"description": "Triggers when a satellite starts processing a command.",
"description_configured": "Triggers when a satellite starts processing a command",
"name": "When a satellite starts processing"
},
"responding": {
"description": "Triggers when a satellite starts responding to a command.",
"description_configured": "Triggers when a satellite starts responding to a command",
"name": "When a satellite starts responding"
},
"idle": {
"description": "Triggers when a satellite goes back to idle.",
"description_configured": "Triggers when a satellite goes back to idle",
"name": "When a satellite goes back to idle"
}
}
}

View File

@@ -0,0 +1,140 @@
"""Provides triggers for assist satellites."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import process_state_match
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
STATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class StateTriggerBase(Trigger):
"""Trigger for assist satellite state changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig, state: str) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._state = state
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
match_config_state = process_state_match(self._state)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state matches the trigger state
if not match_config_state(to_state.state):
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"{entity_id} {self._state}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ListeningTrigger(StateTriggerBase):
"""Trigger for when a satellite starts listening."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the listening trigger."""
super().__init__(hass, config, "listening")
class ProcessingTrigger(StateTriggerBase):
"""Trigger for when a satellite starts processing."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the processing trigger."""
super().__init__(hass, config, "processing")
class RespondingTrigger(StateTriggerBase):
"""Trigger for when a satellite starts responding."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the responding trigger."""
super().__init__(hass, config, "responding")
class IdleTrigger(StateTriggerBase):
"""Trigger for when a satellite goes back to idle."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the idle trigger."""
super().__init__(hass, config, "idle")
TRIGGERS: dict[str, type[Trigger]] = {
"listening": ListeningTrigger,
"processing": ProcessingTrigger,
"responding": RespondingTrigger,
"idle": IdleTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for assist satellites."""
return TRIGGERS

View File

@@ -0,0 +1,19 @@
listening:
target:
entity:
domain: assist_satellite
processing:
target:
entity:
domain: assist_satellite
responding:
target:
entity:
domain: assist_satellite
idle:
target:
entity:
domain: assist_satellite

View File

@@ -285,5 +285,93 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Climate"
"title": "Climate",
"triggers": {
"cooling": {
"description": "Triggers when a climate starts cooling.",
"name": "When a climate starts cooling"
},
"current_humidity_changed": {
"description": "Triggers when the current humidity of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the current humidity goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the current humidity goes below this value.",
"name": "Below"
}
},
"name": "When current humidity changes"
},
"current_temperature_changed": {
"description": "Triggers when the current temperature of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the current temperature goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the current temperature goes below this value.",
"name": "Below"
}
},
"name": "When current temperature changes"
},
"drying": {
"description": "Triggers when a climate starts drying.",
"name": "When a climate starts drying"
},
"heating": {
"description": "Triggers when a climate starts heating.",
"name": "When a climate starts heating"
},
"mode_changed": {
"description": "Triggers when the HVAC mode of a climate changes.",
"fields": {
"hvac_mode": {
"description": "The HVAC modes to trigger on. If empty, triggers on all mode changes.",
"name": "HVAC modes"
}
},
"name": "When HVAC mode changes"
},
"target_humidity_changed": {
"description": "Triggers when the target humidity of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the target humidity goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the target humidity goes below this value.",
"name": "Below"
}
},
"name": "When target humidity changes"
},
"target_temperature_changed": {
"description": "Triggers when the target temperature of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the target temperature goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the target temperature goes below this value.",
"name": "Below"
}
},
"name": "When target temperature changes"
},
"turns_off": {
"description": "Triggers when a climate turns off.",
"name": "When a climate turns off"
},
"turns_on": {
"description": "Triggers when a climate turns on.",
"name": "When a climate turns on"
}
}
}

View File

@@ -0,0 +1,817 @@
"""Provides triggers for climate."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
CONF_ABOVE,
CONF_BELOW,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
DOMAIN,
HVAC_MODES,
HVACMode,
)
CLIMATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
MODE_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(ATTR_HVAC_MODE, default=[]): vol.All(
cv.ensure_list, [vol.In(HVAC_MODES)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
THRESHOLD_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_ABOVE): vol.Coerce(float),
vol.Optional(CONF_BELOW): vol.Coerce(float),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class ClimateTurnsOnTrigger(Trigger):
"""Trigger for when a climate turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate turned on (from off to any other mode)
if (
from_state is not None
and from_state.state == HVACMode.OFF
and to_state.state != HVACMode.OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} turned on",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTurnsOffTrigger(Trigger):
"""Trigger for when a climate turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate turned off (from any mode to off)
if (
from_state is not None
and from_state.state != HVACMode.OFF
and to_state.state == HVACMode.OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} turned off",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateModeChangedTrigger(Trigger):
"""Trigger for when a climate mode changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, MODE_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate mode changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
hvac_modes_filter = self._options[ATTR_HVAC_MODE]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if hvac_mode changed
if from_state is not None and from_state.state != to_state.state:
# If hvac_modes filter is specified, check if the new mode matches
if hvac_modes_filter and to_state.state not in hvac_modes_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} mode changed to {to_state.state}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCoolingTrigger(Trigger):
"""Trigger for when a climate starts cooling."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate cooling trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started cooling
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "cooling" and to_action == "cooling":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started cooling",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateHeatingTrigger(Trigger):
"""Trigger for when a climate starts heating."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate heating trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started heating
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "heating" and to_action == "heating":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started heating",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateDryingTrigger(Trigger):
"""Trigger for when a climate starts drying."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate drying trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started drying
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "drying" and to_action == "drying":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started drying",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTargetTemperatureChangedTrigger(Trigger):
"""Trigger for when a climate target temperature changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate target temperature changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if target temperature changed
from_temp = (
from_state.attributes.get(ATTR_TEMPERATURE) if from_state else None
)
to_temp = to_state.attributes.get(ATTR_TEMPERATURE)
if to_temp is None or from_temp == to_temp:
return
# Apply threshold filters if specified
if above is not None and to_temp <= above:
return
if below is not None and to_temp >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} target temperature changed to {to_temp}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCurrentTemperatureChangedTrigger(Trigger):
"""Trigger for when a climate current temperature changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate current temperature changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if current temperature changed
from_temp = (
from_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
if from_state
else None
)
to_temp = to_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
if to_temp is None or from_temp == to_temp:
return
# Apply threshold filters if specified
if above is not None and to_temp <= above:
return
if below is not None and to_temp >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} current temperature changed to {to_temp}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTargetHumidityChangedTrigger(Trigger):
"""Trigger for when a climate target humidity changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate target humidity changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if target humidity changed
from_humidity = (
from_state.attributes.get(ATTR_HUMIDITY) if from_state else None
)
to_humidity = to_state.attributes.get(ATTR_HUMIDITY)
if to_humidity is None or from_humidity == to_humidity:
return
# Apply threshold filters if specified
if above is not None and to_humidity <= above:
return
if below is not None and to_humidity >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} target humidity changed to {to_humidity}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCurrentHumidityChangedTrigger(Trigger):
"""Trigger for when a climate current humidity changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate current humidity changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if current humidity changed
from_humidity = (
from_state.attributes.get(ATTR_CURRENT_HUMIDITY)
if from_state
else None
)
to_humidity = to_state.attributes.get(ATTR_CURRENT_HUMIDITY)
if to_humidity is None or from_humidity == to_humidity:
return
# Apply threshold filters if specified
if above is not None and to_humidity <= above:
return
if below is not None and to_humidity >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} current humidity changed to {to_humidity}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": ClimateTurnsOnTrigger,
"turns_off": ClimateTurnsOffTrigger,
"mode_changed": ClimateModeChangedTrigger,
"cooling": ClimateCoolingTrigger,
"heating": ClimateHeatingTrigger,
"drying": ClimateDryingTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"current_temperature_changed": ClimateCurrentTemperatureChangedTrigger,
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"current_humidity_changed": ClimateCurrentHumidityChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for climate."""
return TRIGGERS

View File

@@ -0,0 +1,128 @@
turns_on:
target:
entity:
domain: climate
turns_off:
target:
entity:
domain: climate
mode_changed:
target:
entity:
domain: climate
fields:
hvac_mode:
required: false
default: []
selector:
select:
multiple: true
mode: dropdown
options:
- label: "Off"
value: "off"
- label: "Heat"
value: "heat"
- label: "Cool"
value: "cool"
- label: "Heat/Cool"
value: "heat_cool"
- label: "Auto"
value: "auto"
- label: "Dry"
value: "dry"
- label: "Fan only"
value: "fan_only"
cooling:
target:
entity:
domain: climate
heating:
target:
entity:
domain: climate
drying:
target:
entity:
domain: climate
target_temperature_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
step: 0.1
below:
required: false
selector:
number:
mode: box
step: 0.1
current_temperature_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
step: 0.1
below:
required: false
selector:
number:
mode: box
step: 0.1
target_humidity_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
min: 0
max: 100
below:
required: false
selector:
number:
mode: box
min: 0
max: 100
current_humidity_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
min: 0
max: 100
below:
required: false
selector:
number:
mode: box
min: 0
max: 100

View File

@@ -136,5 +136,75 @@
"name": "Toggle tilt"
}
},
"title": "Cover"
"title": "Cover",
"triggers": {
"opens": {
"description": "Triggers when a cover opens.",
"description_configured": "Triggers when a cover opens",
"fields": {
"fully_opened": {
"description": "Only trigger when the cover is fully opened (position at 100%).",
"name": "Fully opened"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover opens"
},
"closes": {
"description": "Triggers when a cover closes.",
"description_configured": "Triggers when a cover closes",
"fields": {
"fully_closed": {
"description": "Only trigger when the cover is fully closed (position at 0%).",
"name": "Fully closed"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover closes"
},
"stops": {
"description": "Triggers when a cover stops moving.",
"description_configured": "Triggers when a cover stops moving",
"fields": {
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover stops moving"
},
"position_changed": {
"description": "Triggers when the position of a cover changes.",
"description_configured": "Triggers when the position of a cover changes",
"fields": {
"lower": {
"description": "The minimum position value to trigger on. Only triggers when position is at or above this value.",
"name": "Lower limit"
},
"upper": {
"description": "The maximum position value to trigger on. Only triggers when position is at or below this value.",
"name": "Upper limit"
},
"above": {
"description": "Only trigger when position is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when position is below this value.",
"name": "Below"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When the position of a cover changes"
}
}
}

View File

@@ -0,0 +1,453 @@
"""Provides triggers for covers."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_CLASS,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
from .const import DOMAIN
CONF_LOWER = "lower"
CONF_UPPER = "upper"
CONF_ABOVE = "above"
CONF_BELOW = "below"
CONF_FULLY_OPENED = "fully_opened"
CONF_FULLY_CLOSED = "fully_closed"
OPENS_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_FULLY_OPENED, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
CLOSES_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_FULLY_CLOSED, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
STOPS_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
POSITION_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Exclusive(CONF_LOWER, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_UPPER, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_ABOVE, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_BELOW, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class CoverOpensTrigger(Trigger):
"""Trigger for when a cover opens."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, OPENS_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover opens trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
fully_opened = self._options[CONF_FULLY_OPENED]
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover opens or is opening
if to_state.state in (CoverState.OPEN, CoverState.OPENING):
# If fully_opened is True, only trigger when position reaches 100
if fully_opened:
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if current_position != 100:
return
# Only trigger on state change, not if already in that state
if from_state and from_state.state == to_state.state:
# For fully_opened, allow triggering when position changes to 100
if fully_opened:
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if from_position == to_position:
return
else:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover opened on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverClosesTrigger(Trigger):
"""Trigger for when a cover closes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLOSES_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover closes trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
fully_closed = self._options[CONF_FULLY_CLOSED]
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover closes or is closing
if to_state.state in (CoverState.CLOSED, CoverState.CLOSING):
# If fully_closed is True, only trigger when position reaches 0
if fully_closed:
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if current_position != 0:
return
# Only trigger on state change, not if already in that state
if from_state and from_state.state == to_state.state:
# For fully_closed, allow triggering when position changes to 0
if fully_closed:
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if from_position == to_position:
return
else:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover closed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverStopsTrigger(Trigger):
"""Trigger for when a cover stops moving."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STOPS_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover stops trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover stops (from opening/closing to open/closed)
if from_state and from_state.state in (
CoverState.OPENING,
CoverState.CLOSING,
):
if to_state.state in (CoverState.OPEN, CoverState.CLOSED):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover stopped on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverPositionChangedTrigger(Trigger):
"""Trigger for when a cover's position changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, POSITION_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover position changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._options = config.options or {}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
lower_limit = self._options.get(CONF_LOWER)
upper_limit = self._options.get(CONF_UPPER)
above_limit = self._options.get(CONF_ABOVE)
below_limit = self._options.get(CONF_BELOW)
device_classes_filter = self._options.get(CONF_DEVICE_CLASS, [])
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Get position values
from_position = (
from_state.attributes.get(ATTR_CURRENT_POSITION) if from_state else None
)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
# Only trigger if position value exists and has changed
if to_position is None or from_position == to_position:
return
# Apply threshold filters if configured
if lower_limit is not None and to_position < lower_limit:
return
if upper_limit is not None and to_position > upper_limit:
return
if above_limit is not None and to_position <= above_limit:
return
if below_limit is not None and to_position >= below_limit:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"from_position": from_position,
"to_position": to_position,
},
f"position changed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"opens": CoverOpensTrigger,
"closes": CoverClosesTrigger,
"stops": CoverStopsTrigger,
"position_changed": CoverPositionChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -0,0 +1,101 @@
opens:
target:
entity:
domain: cover
fields:
fully_opened:
required: false
default: false
selector:
boolean:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
closes:
target:
entity:
domain: cover
fields:
fully_closed:
required: false
default: false
selector:
boolean:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
stops:
target:
entity:
domain: cover
fields:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
position_changed:
target:
entity:
domain: cover
fields:
lower:
required: false
selector:
number:
min: 0
max: 100
mode: box
upper:
required: false
selector:
number:
min: 0
max: 100
mode: box
above:
required: false
selector:
number:
min: 0
max: 100
mode: box
below:
required: false
selector:
number:
min: 0
max: 100
mode: box
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind

View File

@@ -462,5 +462,40 @@
}
}
},
"title": "Light"
"title": "Light",
"triggers": {
"turns_on": {
"description": "Triggers when a light turns on.",
"description_configured": "Triggers when a light turns on",
"name": "When a light turns on"
},
"turns_off": {
"description": "Triggers when a light turns off.",
"description_configured": "Triggers when a light turns off",
"name": "When a light turns off"
},
"brightness_changed": {
"description": "Triggers when the brightness of a light changes.",
"description_configured": "Triggers when the brightness of a light changes",
"fields": {
"lower": {
"description": "The minimum brightness value to trigger on. Only triggers when brightness is at or above this value.",
"name": "Lower limit"
},
"upper": {
"description": "The maximum brightness value to trigger on. Only triggers when brightness is at or below this value.",
"name": "Upper limit"
},
"above": {
"description": "Only trigger when brightness is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when brightness is below this value.",
"name": "Below"
}
},
"name": "When the brightness of a light changes"
}
}
}

View File

@@ -0,0 +1,288 @@
"""Provides triggers for lights."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
ATTR_BRIGHTNESS = "brightness"
CONF_LOWER = "lower"
CONF_UPPER = "upper"
CONF_ABOVE = "above"
CONF_BELOW = "below"
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
BRIGHTNESS_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Exclusive(CONF_LOWER, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_UPPER, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_ABOVE, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_BELOW, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class LightTurnsOnTrigger(Trigger):
"""Trigger for when a light turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when light turns on (from off to on)
if from_state and from_state.state == STATE_OFF and to_state.state == STATE_ON:
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"light turned on on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class LightTurnsOffTrigger(Trigger):
"""Trigger for when a light turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when light turns off (from on to off)
if from_state and from_state.state == STATE_ON and to_state.state == STATE_OFF:
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"light turned off on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class LightBrightnessChangedTrigger(Trigger):
"""Trigger for when a light's brightness changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, BRIGHTNESS_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light brightness changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._options = config.options or {}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
lower_limit = self._options.get(CONF_LOWER)
upper_limit = self._options.get(CONF_UPPER)
above_limit = self._options.get(CONF_ABOVE)
below_limit = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Get brightness values
from_brightness = (
from_state.attributes.get(ATTR_BRIGHTNESS) if from_state else None
)
to_brightness = to_state.attributes.get(ATTR_BRIGHTNESS)
# Only trigger if brightness value exists and has changed
if to_brightness is None or from_brightness == to_brightness:
return
# Apply threshold filters if configured
if lower_limit is not None and to_brightness < lower_limit:
return
if upper_limit is not None and to_brightness > upper_limit:
return
if above_limit is not None and to_brightness <= above_limit:
return
if below_limit is not None and to_brightness >= below_limit:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"from_brightness": from_brightness,
"to_brightness": to_brightness,
},
f"brightness changed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": LightTurnsOnTrigger,
"turns_off": LightTurnsOffTrigger,
"brightness_changed": LightBrightnessChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for lights."""
return TRIGGERS

View File

@@ -0,0 +1,43 @@
turns_on:
target:
entity:
domain: light
turns_off:
target:
entity:
domain: light
brightness_changed:
target:
entity:
domain: light
fields:
lower:
required: false
selector:
number:
min: 0
max: 255
mode: box
upper:
required: false
selector:
number:
min: 0
max: 255
mode: box
above:
required: false
selector:
number:
min: 0
max: 255
mode: box
below:
required: false
selector:
number:
min: 0
max: 255
mode: box

View File

@@ -367,5 +367,63 @@
"name": "Turn up volume"
}
},
"title": "Media player"
"title": "Media player",
"triggers": {
"turns_on": {
"description": "Triggers when a media player turns on.",
"description_configured": "Triggers when a media player turns on",
"name": "When a media player turns on"
},
"turns_off": {
"description": "Triggers when a media player turns off.",
"description_configured": "Triggers when a media player turns off",
"name": "When a media player turns off"
},
"playing": {
"description": "Triggers when a media player starts playing.",
"description_configured": "Triggers when a media player starts playing",
"fields": {
"media_content_type": {
"description": "The media content types to trigger on. If empty, triggers on all content types.",
"name": "Media content types"
}
},
"name": "When a media player starts playing"
},
"paused": {
"description": "Triggers when a media player pauses.",
"description_configured": "Triggers when a media player pauses",
"name": "When a media player pauses"
},
"stopped": {
"description": "Triggers when a media player stops playing.",
"description_configured": "Triggers when a media player stops playing",
"name": "When a media player stops playing"
},
"muted": {
"description": "Triggers when a media player gets muted.",
"description_configured": "Triggers when a media player gets muted",
"name": "When a media player gets muted"
},
"unmuted": {
"description": "Triggers when a media player gets unmuted.",
"description_configured": "Triggers when a media player gets unmuted",
"name": "When a media player gets unmuted"
},
"volume_changed": {
"description": "Triggers when a media player volume changes.",
"description_configured": "Triggers when a media player volume changes",
"fields": {
"above": {
"description": "Only trigger when volume is above this level (0.0-1.0).",
"name": "Above"
},
"below": {
"description": "Only trigger when volume is below this level (0.0-1.0).",
"name": "Below"
}
},
"name": "When a media player volume changes"
}
}
}

View File

@@ -0,0 +1,676 @@
"""Provides triggers for media players."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN,
)
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
PLAYING_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(ATTR_MEDIA_CONTENT_TYPE, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
STOPPED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
MUTED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
UNMUTED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
VOLUME_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional("above"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
vol.Optional("below"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
PAUSED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class MediaPlayerTurnsOnTrigger(Trigger):
"""Trigger for when a media player turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when turning on from off state
if (
from_state is not None
and from_state.state == STATE_OFF
and to_state.state != STATE_OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} turned on",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerTurnsOffTrigger(Trigger):
"""Trigger for when a media player turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when turning off
if (
from_state is not None
and from_state.state != STATE_OFF
and to_state.state == STATE_OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} turned off",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerPlayingTrigger(Trigger):
"""Trigger for when a media player starts playing."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, PLAYING_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player playing trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
media_content_types_filter = self._options[ATTR_MEDIA_CONTENT_TYPE]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when starting to play
if (
from_state is not None
and from_state.state != STATE_PLAYING
and to_state.state == STATE_PLAYING
):
# If media_content_type filter is specified, check if it matches
if media_content_types_filter:
media_content_type = to_state.attributes.get(ATTR_MEDIA_CONTENT_TYPE)
if media_content_type not in media_content_types_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} started playing",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerPausedTrigger(Trigger):
"""Trigger for when a media player pauses."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, PAUSED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player paused trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when pausing
if (
from_state is not None
and from_state.state != STATE_PAUSED
and to_state.state == STATE_PAUSED
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} paused",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerStoppedTrigger(Trigger):
"""Trigger for when a media player stops playing."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STOPPED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player stopped trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when stopping (to idle or off from playing/paused states)
if (
from_state is not None
and from_state.state in (STATE_PLAYING, STATE_PAUSED)
and to_state.state in (STATE_IDLE, STATE_OFF)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} stopped",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerMutedTrigger(Trigger):
"""Trigger for when a media player gets muted."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, MUTED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player muted trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when muting
if (
from_state is not None
and not from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
and to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} muted",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerUnmutedTrigger(Trigger):
"""Trigger for when a media player gets unmuted."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, UNMUTED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player unmuted trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when unmuting
if (
from_state is not None
and from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
and not to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} unmuted",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerVolumeChangedTrigger(Trigger):
"""Trigger for when a media player volume changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, VOLUME_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player volume changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above_threshold = self._options.get("above")
below_threshold = self._options.get("below")
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Get volume levels
old_volume = (
from_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
if from_state is not None
else None
)
new_volume = to_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
# Volume must have changed
if old_volume == new_volume or new_volume is None:
return
# Check thresholds if specified
if above_threshold is not None and new_volume <= above_threshold:
return
if below_threshold is not None and new_volume >= below_threshold:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} volume changed",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": MediaPlayerTurnsOnTrigger,
"turns_off": MediaPlayerTurnsOffTrigger,
"playing": MediaPlayerPlayingTrigger,
"paused": MediaPlayerPausedTrigger,
"stopped": MediaPlayerStoppedTrigger,
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": MediaPlayerVolumeChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for media players."""
return TRIGGERS

View File

@@ -0,0 +1,65 @@
turns_on:
target:
entity:
domain: media_player
turns_off:
target:
entity:
domain: media_player
playing:
target:
entity:
domain: media_player
fields:
media_content_type:
required: false
default: []
selector:
select:
multiple: true
custom_value: true
options: []
paused:
target:
entity:
domain: media_player
stopped:
target:
entity:
domain: media_player
muted:
target:
entity:
domain: media_player
unmuted:
target:
entity:
domain: media_player
volume_changed:
target:
entity:
domain: media_player
fields:
above:
required: false
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider
below:
required: false
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider

View File

@@ -0,0 +1,600 @@
"""Test alarm control panel trigger."""
from homeassistant.components import automation
from homeassistant.components.alarm_control_panel.const import AlarmControlPanelState
from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
async def test_alarm_armed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm armed trigger fires when an alarm is armed."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.armed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Trigger armed home
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Trigger armed away
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_AWAY)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Trigger armed night
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_NIGHT)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Trigger armed vacation
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_VACATION)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Trigger armed custom bypass
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_CUSTOM_BYPASS)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_armed_trigger_with_mode_filter(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm armed trigger with mode filter."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.armed",
"target": {
CONF_ENTITY_ID: entity_id,
},
"options": {
"mode": [
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
],
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Trigger matching armed home mode
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Trigger matching armed away mode
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_AWAY)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Trigger non-matching armed night mode - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_NIGHT)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Trigger non-matching armed vacation mode - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_VACATION)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_alarm_armed_trigger_ignores_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm armed trigger ignores unavailable states."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.armed",
"target": {
CONF_ENTITY_ID: entity_id,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to unavailable - should not trigger
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Trigger armed after unavailable - should trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_armed_trigger_ignores_non_armed_states(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm armed trigger ignores non-armed states."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.armed",
"target": {
CONF_ENTITY_ID: entity_id,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to disarmed - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to triggered - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to arming - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMING)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to pending - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.PENDING)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to disarming - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMING)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_alarm_armed_trigger_from_unknown_state(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the trigger fires when entity goes from unknown/None to first armed state."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Do NOT set any initial state - entity starts with None state
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.armed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# First armed state should trigger even though entity had no previous state
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_disarmed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm disarmed trigger fires when an alarm is disarmed."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.disarmed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Trigger disarmed
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_disarmed_trigger_ignores_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm disarmed trigger ignores unavailable states."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.disarmed",
"target": {
CONF_ENTITY_ID: entity_id,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to unavailable - should not trigger
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Trigger disarmed after unavailable - should trigger
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_disarmed_trigger_ignores_non_disarmed_states(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm disarmed trigger ignores non-disarmed states."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.disarmed",
"target": {
CONF_ENTITY_ID: entity_id,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to armed home - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to triggered - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to arming - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMING)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_alarm_disarmed_trigger_from_unknown_state(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the trigger fires when entity goes from unknown/None to disarmed state."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Do NOT set any initial state - entity starts with None state
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.disarmed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# First disarmed state should trigger even though entity had no previous state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_triggered_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm triggered trigger fires when an alarm is triggered."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.triggered",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Trigger alarm triggered
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_triggered_trigger_ignores_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm triggered trigger ignores unavailable states."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.triggered",
"target": {
CONF_ENTITY_ID: entity_id,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to unavailable - should not trigger
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Trigger alarm triggered after unavailable - should trigger
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_alarm_triggered_trigger_ignores_non_triggered_states(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the alarm triggered trigger ignores non-triggered states."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Set initial state
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.triggered",
"target": {
CONF_ENTITY_ID: entity_id,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to armed home - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMED_HOME)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to disarmed - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.DISARMED)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set to arming - should not trigger
hass.states.async_set(entity_id, AlarmControlPanelState.ARMING)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_alarm_triggered_trigger_from_unknown_state(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the trigger fires when entity goes from unknown/None to triggered state."""
entity_id = "alarm_control_panel.test_alarm"
await async_setup_component(hass, "alarm_control_panel", {})
# Do NOT set any initial state - entity starts with None state
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "alarm_control_panel.triggered",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# First triggered state should trigger even though entity had no previous state
hass.states.async_set(entity_id, AlarmControlPanelState.TRIGGERED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id

View File

@@ -0,0 +1,219 @@
"""The tests for the Assist satellite automation."""
import pytest
from homeassistant.components import automation
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_TARGET
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.common import mock_component
@pytest.fixture(autouse=True)
def setup_comp(hass: HomeAssistant) -> None:
"""Initialize components."""
mock_component(hass, "group")
@pytest.mark.parametrize(
("trigger", "state"),
[
("assist_satellite.listening", "listening"),
("assist_satellite.processing", "processing"),
("assist_satellite.responding", "responding"),
("assist_satellite.idle", "idle"),
],
)
async def test_trigger_fires_on_state_change(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger: str,
state: str,
) -> None:
"""Test that the trigger fires when satellite changes to the specified state."""
hass.states.async_set("assist_satellite.satellite_1", "idle")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: trigger,
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
},
"action": {
"service": "test.automation",
"data_template": {"id": "{{ trigger.id}}"},
},
}
},
)
hass.states.async_set("assist_satellite.satellite_1", state, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_listening_does_not_fire_on_other_states(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that listening trigger does not fire on other states."""
hass.states.async_set("assist_satellite.satellite_1", "idle")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "assist_satellite.listening",
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("assist_satellite.satellite_1", "processing")
await hass.async_block_till_done()
assert len(service_calls) == 0
hass.states.async_set("assist_satellite.satellite_1", "responding")
await hass.async_block_till_done()
assert len(service_calls) == 0
hass.states.async_set("assist_satellite.satellite_1", "idle")
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_does_not_fire_on_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that trigger does not fire when state becomes unavailable."""
hass.states.async_set("assist_satellite.satellite_1", "idle")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "assist_satellite.listening",
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("assist_satellite.satellite_1", "unavailable")
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_fires_with_multiple_entities(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the firing with multiple entities."""
hass.states.async_set("assist_satellite.satellite_1", "idle")
hass.states.async_set("assist_satellite.satellite_2", "idle")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "assist_satellite.listening",
CONF_TARGET: {
CONF_ENTITY_ID: [
"assist_satellite.satellite_1",
"assist_satellite.satellite_2",
]
},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("assist_satellite.satellite_1", "listening")
await hass.async_block_till_done()
assert len(service_calls) == 1
hass.states.async_set("assist_satellite.satellite_2", "listening")
await hass.async_block_till_done()
assert len(service_calls) == 2
async def test_trigger_data_available(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that trigger data is available in action."""
hass.states.async_set("assist_satellite.satellite_1", "idle")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "assist_satellite.listening",
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
},
"action": {
"service": "test.automation",
"data_template": {
"entity_id": "{{ trigger.entity_id }}",
"from_state": "{{ trigger.from_state.state }}",
"to_state": "{{ trigger.to_state.state }}",
},
},
}
},
)
hass.states.async_set("assist_satellite.satellite_1", "listening")
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data["entity_id"] == "assist_satellite.satellite_1"
assert service_calls[0].data["from_state"] == "idle"
assert service_calls[0].data["to_state"] == "listening"
async def test_idle_trigger_fires_when_returning_to_idle(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that idle trigger fires when satellite returns to idle."""
hass.states.async_set("assist_satellite.satellite_1", "listening")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "assist_satellite.idle",
CONF_TARGET: {CONF_ENTITY_ID: "assist_satellite.satellite_1"},
},
"action": {"service": "test.automation"},
}
},
)
# Go to processing state first
hass.states.async_set("assist_satellite.satellite_1", "processing")
await hass.async_block_till_done()
assert len(service_calls) == 0
# Go back to idle
hass.states.async_set("assist_satellite.satellite_1", "idle", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id

View File

@@ -0,0 +1,416 @@
"""The tests for the Climate automation."""
import pytest
from homeassistant.components import automation
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HVAC_ACTION,
ATTR_HUMIDITY,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_ENTITY_ID,
CONF_PLATFORM,
CONF_TARGET,
)
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.common import mock_component
@pytest.fixture(autouse=True)
def setup_comp(hass: HomeAssistant) -> None:
"""Initialize components."""
mock_component(hass, "group")
async def test_turns_on_trigger_fires_when_turning_on(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that turns_on trigger fires when climate turns on."""
hass.states.async_set("climate.test", "off")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.turns_on",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "heat", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_turns_on_trigger_does_not_fire_when_already_on(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that turns_on trigger does not fire when already on."""
hass.states.async_set("climate.test", "heat")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.turns_on",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "cool")
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_turns_off_trigger_fires_when_turning_off(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that turns_off trigger fires when climate turns off."""
hass.states.async_set("climate.test", "heat")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.turns_off",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "off", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_mode_changed_trigger_fires_on_mode_change(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that mode_changed trigger fires when mode changes."""
hass.states.async_set("climate.test", "heat")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.mode_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "cool", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_mode_changed_trigger_filters_by_hvac_mode(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that mode_changed trigger filters by hvac_mode."""
hass.states.async_set("climate.test", "off")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.mode_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
"hvac_mode": ["heat", "cool"],
},
"action": {"service": "test.automation"},
}
},
)
# Should trigger for heat
hass.states.async_set("climate.test", "heat")
await hass.async_block_till_done()
assert len(service_calls) == 1
# Should trigger for cool
hass.states.async_set("climate.test", "cool")
await hass.async_block_till_done()
assert len(service_calls) == 2
# Should not trigger for auto
hass.states.async_set("climate.test", "auto")
await hass.async_block_till_done()
assert len(service_calls) == 2
async def test_cooling_trigger_fires_when_cooling_starts(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that cooling trigger fires when climate starts cooling."""
hass.states.async_set("climate.test", "cool", {ATTR_HVAC_ACTION: "idle"})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.cooling",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "cool", {ATTR_HVAC_ACTION: "cooling"}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_heating_trigger_fires_when_heating_starts(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that heating trigger fires when climate starts heating."""
hass.states.async_set("climate.test", "heat", {ATTR_HVAC_ACTION: "idle"})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.heating",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "heat", {ATTR_HVAC_ACTION: "heating"}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_drying_trigger_fires_when_drying_starts(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that drying trigger fires when climate starts drying."""
hass.states.async_set("climate.test", "dry", {ATTR_HVAC_ACTION: "idle"})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.drying",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "dry", {ATTR_HVAC_ACTION: "drying"}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_target_temperature_changed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that target_temperature_changed trigger fires."""
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 20})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.target_temperature_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 22}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_target_temperature_changed_trigger_with_above(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that target_temperature_changed trigger filters by above threshold."""
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 20})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.target_temperature_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
"above": 22,
},
"action": {"service": "test.automation"},
}
},
)
# Should not trigger at 21
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 21})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Should trigger at 23
hass.states.async_set("climate.test", "heat", {ATTR_TEMPERATURE: 23})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_current_temperature_changed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that current_temperature_changed trigger fires."""
hass.states.async_set("climate.test", "heat", {ATTR_CURRENT_TEMPERATURE: 20})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.current_temperature_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "heat", {ATTR_CURRENT_TEMPERATURE: 21}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_target_humidity_changed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that target_humidity_changed trigger fires."""
hass.states.async_set("climate.test", "dry", {ATTR_HUMIDITY: 50})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.target_humidity_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "dry", {ATTR_HUMIDITY: 60}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_current_humidity_changed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that current_humidity_changed trigger fires."""
hass.states.async_set("climate.test", "dry", {ATTR_CURRENT_HUMIDITY: 50})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.current_humidity_changed",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "dry", {ATTR_CURRENT_HUMIDITY: 55}, context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_does_not_fire_on_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that trigger does not fire when state becomes unavailable."""
hass.states.async_set("climate.test", "heat")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "climate.turns_off",
CONF_TARGET: {CONF_ENTITY_ID: "climate.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("climate.test", "unavailable")
await hass.async_block_till_done()
assert len(service_calls) == 0

View File

@@ -0,0 +1,591 @@
"""The tests for the Cover automation."""
import pytest
from homeassistant.components import automation
from homeassistant.components.cover import ATTR_CURRENT_POSITION
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_TARGET
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.common import mock_component
@pytest.fixture(autouse=True)
def setup_comp(hass: HomeAssistant) -> None:
"""Initialize components."""
mock_component(hass, "group")
async def test_opens_trigger_fires_on_opening(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that opens trigger fires when cover starts opening."""
hass.states.async_set("cover.test", "closed")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "opening", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_opens_trigger_fires_on_open(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that opens trigger fires when cover becomes open."""
hass.states.async_set("cover.test", "closed")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "open", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_opens_trigger_fully_opened_option(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that opens trigger with fully_opened only fires at position 100."""
hass.states.async_set("cover.test", "closed", {ATTR_CURRENT_POSITION: 0})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
"fully_opened": True,
},
"action": {"service": "test.automation"},
}
},
)
# Should not trigger at position 50
hass.states.async_set("cover.test", "opening", {ATTR_CURRENT_POSITION: 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Should trigger at position 100
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_opens_trigger_device_class_filter(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that opens trigger filters by device class."""
hass.states.async_set("cover.curtain", "closed", {"device_class": "curtain"})
hass.states.async_set("cover.garage", "closed", {"device_class": "garage"})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {
CONF_ENTITY_ID: ["cover.curtain", "cover.garage"]
},
"device_class": ["curtain"],
},
"action": {"service": "test.automation"},
}
},
)
# Should trigger for curtain
hass.states.async_set("cover.curtain", "opening", {"device_class": "curtain"})
await hass.async_block_till_done()
assert len(service_calls) == 1
# Should not trigger for garage
hass.states.async_set("cover.garage", "opening", {"device_class": "garage"})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_does_not_fire_on_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that trigger does not fire when state becomes unavailable."""
hass.states.async_set("cover.test", "closed")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "unavailable")
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_trigger_data_available(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that trigger data is available in action."""
hass.states.async_set("cover.test", "closed")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {
"service": "test.automation",
"data_template": {
"entity_id": "{{ trigger.entity_id }}",
"from_state": "{{ trigger.from_state.state }}",
"to_state": "{{ trigger.to_state.state }}",
},
},
}
},
)
hass.states.async_set("cover.test", "opening")
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data["entity_id"] == "cover.test"
assert service_calls[0].data["from_state"] == "closed"
assert service_calls[0].data["to_state"] == "opening"
async def test_fires_with_multiple_entities(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the firing with multiple entities."""
hass.states.async_set("cover.test1", "closed")
hass.states.async_set("cover.test2", "closed")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.opens",
CONF_TARGET: {CONF_ENTITY_ID: ["cover.test1", "cover.test2"]},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test1", "opening")
await hass.async_block_till_done()
assert len(service_calls) == 1
hass.states.async_set("cover.test2", "opening")
await hass.async_block_till_done()
assert len(service_calls) == 2
async def test_closes_trigger_fires_on_closing(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that closes trigger fires when cover starts closing."""
hass.states.async_set("cover.test", "open")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.closes",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "closing", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_closes_trigger_fires_on_closed(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that closes trigger fires when cover becomes closed."""
hass.states.async_set("cover.test", "open")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.closes",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "closed", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_closes_trigger_fully_closed_option(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that closes trigger with fully_closed only fires at position 0."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.closes",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
"fully_closed": True,
},
"action": {"service": "test.automation"},
}
},
)
# Should not trigger at position 50
hass.states.async_set("cover.test", "closing", {ATTR_CURRENT_POSITION: 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Should trigger at position 0
hass.states.async_set("cover.test", "closed", {ATTR_CURRENT_POSITION: 0})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_stops_trigger_fires_from_opening_to_open(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that stops trigger fires when cover stops from opening."""
hass.states.async_set("cover.test", "opening")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.stops",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "open", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_stops_trigger_fires_from_closing_to_closed(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that stops trigger fires when cover stops from closing."""
hass.states.async_set("cover.test", "closing")
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.stops",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set("cover.test", "closed", context=context)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_stops_trigger_does_not_fire_on_other_changes(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that stops trigger does not fire on other state changes."""
hass.states.async_set("cover.test", "closed")
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.stops",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
# Should not trigger when going from closed to opening
hass.states.async_set("cover.test", "opening")
await hass.async_block_till_done()
assert len(service_calls) == 0
# Should not trigger when going from open to closing
hass.states.async_set("cover.test", "open")
await hass.async_block_till_done()
hass.states.async_set("cover.test", "closing")
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_position_changed_trigger_fires(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that position_changed trigger fires when position changes."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
context = Context()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.position_changed",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set(
"cover.test", "open", {ATTR_CURRENT_POSITION: 75}, context=context
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].context.parent_id == context.id
async def test_position_changed_trigger_with_lower_limit(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test position_changed trigger with lower limit."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.position_changed",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
"lower": 50,
},
"action": {"service": "test.automation"},
}
},
)
# Should trigger at position 75 (>= 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
await hass.async_block_till_done()
assert len(service_calls) == 1
# Should not trigger at position 25 (< 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 25})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_position_changed_trigger_with_upper_limit(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test position_changed trigger with upper limit."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 0})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.position_changed",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
"upper": 50,
},
"action": {"service": "test.automation"},
}
},
)
# Should trigger at position 25 (<= 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 25})
await hass.async_block_till_done()
assert len(service_calls) == 1
# Should not trigger at position 75 (> 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_position_changed_trigger_with_above_limit(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test position_changed trigger with above limit."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 0})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.position_changed",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
"above": 50,
},
"action": {"service": "test.automation"},
}
},
)
# Should trigger at position 75 (> 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
await hass.async_block_till_done()
assert len(service_calls) == 1
# Should not trigger at position 50 (= 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 50})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_position_changed_trigger_with_below_limit(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test position_changed trigger with below limit."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.position_changed",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
"below": 50,
},
"action": {"service": "test.automation"},
}
},
)
# Should trigger at position 25 (< 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 25})
await hass.async_block_till_done()
assert len(service_calls) == 1
# Should not trigger at position 50 (= 50)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 50})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_position_trigger_data_includes_positions(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that position trigger data includes position values."""
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 100})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "cover.position_changed",
CONF_TARGET: {CONF_ENTITY_ID: "cover.test"},
},
"action": {
"service": "test.automation",
"data_template": {
"from_position": "{{ trigger.from_position }}",
"to_position": "{{ trigger.to_position }}",
},
},
}
},
)
hass.states.async_set("cover.test", "open", {ATTR_CURRENT_POSITION: 75})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data["from_position"] == "100"
assert service_calls[0].data["to_position"] == "75"

View File

@@ -0,0 +1,650 @@
"""Test light trigger."""
from homeassistant.components import automation
from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
async def test_light_turns_on_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the light turns on trigger fires when a light turns on."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state to off
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.turns_on",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Turn light on - should trigger
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Turn light on again while already on - should not trigger
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_light_turns_on_trigger_ignores_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the light turns on trigger ignores unavailable states."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state to off
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.turns_on",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to unavailable - should not trigger
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Turn light on after unavailable - should not trigger (from unavailable)
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Turn light off
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Turn light on after being off - should trigger
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_light_turns_on_multiple_lights(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the turns on trigger with multiple lights."""
entity_id_1 = "light.test_light_1"
entity_id_2 = "light.test_light_2"
await async_setup_component(hass, "light", {})
# Set initial states to off
hass.states.async_set(entity_id_1, STATE_OFF)
hass.states.async_set(entity_id_2, STATE_OFF)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.turns_on",
"target": {
CONF_ENTITY_ID: [entity_id_1, entity_id_2],
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Turn first light on - should trigger
hass.states.async_set(entity_id_1, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_1
service_calls.clear()
# Turn second light on - should trigger
hass.states.async_set(entity_id_2, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_2
async def test_light_turns_off_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the light turns off trigger fires when a light turns off."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state to on
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.turns_off",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Turn light off - should trigger
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Turn light off again while already off - should not trigger
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_light_turns_off_trigger_ignores_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the light turns off trigger ignores unavailable states."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state to on
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.turns_off",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to unavailable - should not trigger
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Turn light off after unavailable - should not trigger (from unavailable)
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Turn light on
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Turn light off after being on - should trigger
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_light_brightness_changed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the brightness changed trigger fires when brightness changes."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
"from_brightness": "{{ trigger.from_brightness }}",
"to_brightness": "{{ trigger.to_brightness }}",
},
},
}
},
)
# Change brightness - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
assert service_calls[0].data["from_brightness"] == "100"
assert service_calls[0].data["to_brightness"] == "150"
service_calls.clear()
# Change brightness again - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data["from_brightness"] == "150"
assert service_calls[0].data["to_brightness"] == "200"
async def test_light_brightness_changed_trigger_same_brightness(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the brightness changed trigger does not fire when brightness is the same."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Update state but keep brightness the same - should not trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_light_brightness_changed_trigger_with_lower_limit(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the brightness changed trigger with lower limit."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 50})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
"options": {
"lower": 100,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change to brightness below lower limit - should not trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 75})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Change to brightness at lower limit - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Change to brightness above lower limit - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_light_brightness_changed_trigger_with_upper_limit(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the brightness changed trigger with upper limit."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
"options": {
"upper": 150,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change to brightness above upper limit - should not trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 180})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Change to brightness at upper limit - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Change to brightness below upper limit - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_light_brightness_changed_trigger_with_above(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the brightness changed trigger with above threshold."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 50})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
"options": {
"above": 100,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change to brightness at threshold - should not trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Change to brightness above threshold - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 101})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Change to brightness well above threshold - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_light_brightness_changed_trigger_with_below(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test the brightness changed trigger with below threshold."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 200})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
"options": {
"below": 100,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change to brightness at threshold - should not trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Change to brightness below threshold - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 99})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Change to brightness well below threshold - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 50})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_light_brightness_changed_trigger_ignores_unavailable(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the brightness changed trigger ignores unavailable states."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state with brightness
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Set to unavailable - should not trigger
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Change brightness after unavailable - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 150})
await hass.async_block_till_done()
assert len(service_calls) == 1
async def test_light_brightness_changed_trigger_from_no_brightness(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the trigger fires when brightness is added."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state without brightness (on/off only light)
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
"from_brightness": "{{ trigger.from_brightness }}",
"to_brightness": "{{ trigger.to_brightness }}",
},
},
}
},
)
# Add brightness attribute - should trigger
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data["from_brightness"] == "None"
assert service_calls[0].data["to_brightness"] == "100"
async def test_light_brightness_changed_trigger_no_brightness(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the trigger does not fire when brightness is not present."""
entity_id = "light.test_light"
await async_setup_component(hass, "light", {})
# Set initial state without brightness
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "light.brightness_changed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Turn light off and on without brightness - should not trigger
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 0
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(service_calls) == 0

View File

@@ -0,0 +1,536 @@
"""Test media_player trigger."""
from homeassistant.components import automation
from homeassistant.components.media_player import ATTR_MEDIA_CONTENT_TYPE
from homeassistant.const import (
CONF_ENTITY_ID,
STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
async def test_turns_on_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the turns on trigger fires when a media player turns on."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state to off
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.turns_on",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Turn on - should trigger
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Already on, change to playing - should not trigger
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_turns_off_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the turns off trigger fires when a media player turns off."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state to playing
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.turns_off",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Turn off - should trigger
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Already off - should not trigger
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_playing_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the playing trigger fires when a media player starts playing."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state to idle
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.playing",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Start playing - should trigger
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Pause then play again - should trigger
hass.states.async_set(entity_id, STATE_PAUSED)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_playing_trigger_with_media_content_type_filter(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the playing trigger with media content type filter."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state to idle
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.playing",
"target": {
CONF_ENTITY_ID: entity_id,
},
"options": {
"media_content_type": ["music", "video"],
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Start playing music - should trigger
hass.states.async_set(
entity_id, STATE_PLAYING, {ATTR_MEDIA_CONTENT_TYPE: "music"}
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Stop and play video - should trigger
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
hass.states.async_set(
entity_id, STATE_PLAYING, {ATTR_MEDIA_CONTENT_TYPE: "video"}
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Stop and play podcast - should not trigger (not in filter)
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
hass.states.async_set(
entity_id, STATE_PLAYING, {ATTR_MEDIA_CONTENT_TYPE: "podcast"}
)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_paused_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the paused trigger fires when a media player pauses."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state to playing
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.paused",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Pause - should trigger
hass.states.async_set(entity_id, STATE_PAUSED)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Already paused - should not trigger
hass.states.async_set(entity_id, STATE_PAUSED)
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_stopped_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the stopped trigger fires when a media player stops playing."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state to playing
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.stopped",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Stop to idle - should trigger
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Start playing again and then stop to off - should trigger
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Stop from paused - should trigger
hass.states.async_set(entity_id, STATE_PLAYING)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_PAUSED)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_IDLE)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
async def test_muted_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the muted trigger fires when a media player gets muted."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state with volume unmuted
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_MUTED
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.muted",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Mute - should trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Already muted - should not trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True})
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_unmuted_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the unmuted trigger fires when a media player gets unmuted."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state with volume muted
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_MUTED
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.unmuted",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Unmute - should trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Already unmuted - should not trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False})
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_volume_changed_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the volume changed trigger fires when volume changes."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state with volume
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.5})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.volume_changed",
"target": {CONF_ENTITY_ID: entity_id},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change volume - should trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Same volume - should not trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_volume_changed_trigger_with_above_threshold(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the volume changed trigger with above threshold."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state with volume
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.3})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.volume_changed",
"target": {CONF_ENTITY_ID: entity_id},
"options": {
"above": 0.5,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change volume above threshold - should trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Change volume but still below threshold - should not trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.4})
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_volume_changed_trigger_with_below_threshold(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test that the volume changed trigger with below threshold."""
entity_id = "media_player.test"
await async_setup_component(hass, "media_player", {})
# Set initial state with volume
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.7})
await hass.async_block_till_done()
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"triggers": {
"trigger": "media_player.volume_changed",
"target": {CONF_ENTITY_ID: entity_id},
"options": {
"below": 0.5,
},
},
"actions": {
"action": "test.automation",
"data": {
CONF_ENTITY_ID: "{{ trigger.entity_id }}",
},
},
}
},
)
# Change volume below threshold - should trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.3})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Change volume but still above threshold - should not trigger
hass.states.async_set(entity_id, STATE_PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0.6})
await hass.async_block_till_done()
assert len(service_calls) == 0