diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 1dbf972a26f..d252f84677d 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -45,7 +45,7 @@ from .const import ( CoverEntityFeature, CoverState, ) -from .trigger import CoverClosedTriggerBase, CoverOpenedTriggerBase +from .trigger import make_cover_closed_trigger, make_cover_opened_trigger _LOGGER = logging.getLogger(__name__) @@ -74,13 +74,13 @@ __all__ = [ "INTENT_OPEN_COVER", "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", - "CoverClosedTriggerBase", "CoverDeviceClass", "CoverEntity", "CoverEntityDescription", "CoverEntityFeature", - "CoverOpenedTriggerBase", "CoverState", + "make_cover_closed_trigger", + "make_cover_opened_trigger", ] diff --git a/homeassistant/components/cover/icons.json b/homeassistant/components/cover/icons.json index 91775fe634d..8ad6123cfb4 100644 --- a/homeassistant/components/cover/icons.json +++ b/homeassistant/components/cover/icons.json @@ -108,5 +108,37 @@ "toggle_cover_tilt": { "service": "mdi:arrow-top-right-bottom-left" } + }, + "triggers": { + "awning_closed": { + "trigger": "mdi:storefront-outline" + }, + "awning_opened": { + "trigger": "mdi:storefront-outline" + }, + "blind_closed": { + "trigger": "mdi:blinds-horizontal-closed" + }, + "blind_opened": { + "trigger": "mdi:blinds-horizontal" + }, + "curtain_closed": { + "trigger": "mdi:curtains-closed" + }, + "curtain_opened": { + "trigger": "mdi:curtains" + }, + "shade_closed": { + "trigger": "mdi:roller-shade-closed" + }, + "shade_opened": { + "trigger": "mdi:roller-shade" + }, + "shutter_closed": { + "trigger": "mdi:window-shutter" + }, + "shutter_opened": { + "trigger": "mdi:window-shutter-open" + } } } diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index f0d42685e85..67c7ab6c245 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted covers to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "action_type": { "close": "Close {entity_name}", @@ -82,6 +86,15 @@ "name": "Window" } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "close_cover": { "description": "Closes a cover.", @@ -136,5 +149,107 @@ "name": "Toggle tilt" } }, - "title": "Cover" + "title": "Cover", + "triggers": { + "awning_closed": { + "description": "Triggers after one or more awnings close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Awning closed" + }, + "awning_opened": { + "description": "Triggers after one or more awnings open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Awning opened" + }, + "blind_closed": { + "description": "Triggers after one or more blinds close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Blind closed" + }, + "blind_opened": { + "description": "Triggers after one or more blinds open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Blind opened" + }, + "curtain_closed": { + "description": "Triggers after one or more curtains close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Curtain closed" + }, + "curtain_opened": { + "description": "Triggers after one or more curtains open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Curtain opened" + }, + "shade_closed": { + "description": "Triggers after one or more shades close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shade closed" + }, + "shade_opened": { + "description": "Triggers after one or more shades open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shade opened" + }, + "shutter_closed": { + "description": "Triggers after one or more shutters close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shutter closed" + }, + "shutter_opened": { + "description": "Triggers after one or more shutters open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shutter opened" + } + } } diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index bbd669b7856..f7b7df60116 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -1,14 +1,13 @@ """Provides triggers for covers.""" -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import get_device_class -from homeassistant.helpers.trigger import EntityTriggerBase +from homeassistant.helpers.trigger import EntityTriggerBase, Trigger from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .const import ATTR_IS_CLOSED, DOMAIN +from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass def get_device_class_or_undefined( @@ -24,7 +23,6 @@ def get_device_class_or_undefined( class CoverTriggerBase(EntityTriggerBase): """Base trigger for cover state changes.""" - _domains = {BINARY_SENSOR_DOMAIN, DOMAIN} _binary_sensor_target_state: str _cover_is_closed_target_value: bool _device_classes: dict[str, str] @@ -59,15 +57,60 @@ class CoverTriggerBase(EntityTriggerBase): return from_state.state != to_state.state -class CoverOpenedTriggerBase(CoverTriggerBase): - """Base trigger for cover opened state changes.""" +def make_cover_opened_trigger( + *, device_classes: dict[str, str], domains: set[str] | None = None +) -> type[CoverTriggerBase]: + """Create a trigger cover_opened.""" - _binary_sensor_target_state = STATE_ON - _cover_is_closed_target_value = False + class CoverOpenedTrigger(CoverTriggerBase): + """Trigger for cover opened state changes.""" + + _binary_sensor_target_state = STATE_ON + _cover_is_closed_target_value = False + _domains = domains or {DOMAIN} + _device_classes = device_classes + + return CoverOpenedTrigger -class CoverClosedTriggerBase(CoverTriggerBase): - """Base trigger for cover closed state changes.""" +def make_cover_closed_trigger( + *, device_classes: dict[str, str], domains: set[str] | None = None +) -> type[CoverTriggerBase]: + """Create a trigger cover_closed.""" - _binary_sensor_target_state = STATE_OFF - _cover_is_closed_target_value = True + class CoverClosedTrigger(CoverTriggerBase): + """Trigger for cover closed state changes.""" + + _binary_sensor_target_state = STATE_OFF + _cover_is_closed_target_value = True + _domains = domains or {DOMAIN} + _device_classes = device_classes + + return CoverClosedTrigger + + +# Concrete triggers for cover device classes (cover-only, no binary sensor) + +DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING} +DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND} +DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN} +DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE} +DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER} + +TRIGGERS: dict[str, type[Trigger]] = { + "awning_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_AWNING), + "awning_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_AWNING), + "blind_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_BLIND), + "blind_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_BLIND), + "curtain_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_CURTAIN), + "curtain_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_CURTAIN), + "shade_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHADE), + "shade_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHADE), + "shutter_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHUTTER), + "shutter_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHUTTER), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for covers.""" + return TRIGGERS diff --git a/homeassistant/components/cover/triggers.yaml b/homeassistant/components/cover/triggers.yaml new file mode 100644 index 00000000000..4b9d0a054dc --- /dev/null +++ b/homeassistant/components/cover/triggers.yaml @@ -0,0 +1,81 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +awning_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: awning + +awning_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: awning + +blind_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: blind + +blind_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: blind + +curtain_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: curtain + +curtain_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: curtain + +shade_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shade + +shade_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shade + +shutter_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shutter + +shutter_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shutter diff --git a/homeassistant/components/door/trigger.py b/homeassistant/components/door/trigger.py index 2c6f1b8aab9..f301fa16018 100644 --- a/homeassistant/components/door/trigger.py +++ b/homeassistant/components/door/trigger.py @@ -6,9 +6,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, - CoverClosedTriggerBase, CoverDeviceClass, - CoverOpenedTriggerBase, + make_cover_closed_trigger, + make_cover_opened_trigger, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import Trigger @@ -19,21 +19,15 @@ DEVICE_CLASSES_DOOR: dict[str, str] = { } -class DoorOpenedTrigger(CoverOpenedTriggerBase): - """Trigger for door opened state changes.""" - - _device_classes = DEVICE_CLASSES_DOOR - - -class DoorClosedTrigger(CoverClosedTriggerBase): - """Trigger for door closed state changes.""" - - _device_classes = DEVICE_CLASSES_DOOR - - TRIGGERS: dict[str, type[Trigger]] = { - "opened": DoorOpenedTrigger, - "closed": DoorClosedTrigger, + "opened": make_cover_opened_trigger( + device_classes=DEVICE_CLASSES_DOOR, + domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN}, + ), + "closed": make_cover_closed_trigger( + device_classes=DEVICE_CLASSES_DOOR, + domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN}, + ), } diff --git a/homeassistant/components/garage_door/trigger.py b/homeassistant/components/garage_door/trigger.py index 31a0bf04458..90eebf19227 100644 --- a/homeassistant/components/garage_door/trigger.py +++ b/homeassistant/components/garage_door/trigger.py @@ -6,9 +6,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, - CoverClosedTriggerBase, CoverDeviceClass, - CoverOpenedTriggerBase, + make_cover_closed_trigger, + make_cover_opened_trigger, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import Trigger @@ -19,21 +19,15 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = { } -class GarageDoorOpenedTrigger(CoverOpenedTriggerBase): - """Trigger for garage door opened state changes.""" - - _device_classes = DEVICE_CLASSES_GARAGE_DOOR - - -class GarageDoorClosedTrigger(CoverClosedTriggerBase): - """Trigger for garage door closed state changes.""" - - _device_classes = DEVICE_CLASSES_GARAGE_DOOR - - TRIGGERS: dict[str, type[Trigger]] = { - "opened": GarageDoorOpenedTrigger, - "closed": GarageDoorClosedTrigger, + "opened": make_cover_opened_trigger( + device_classes=DEVICE_CLASSES_GARAGE_DOOR, + domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN}, + ), + "closed": make_cover_closed_trigger( + device_classes=DEVICE_CLASSES_GARAGE_DOOR, + domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN}, + ), } diff --git a/tests/components/cover/test_trigger.py b/tests/components/cover/test_trigger.py new file mode 100644 index 00000000000..9929b8847bb --- /dev/null +++ b/tests/components/cover/test_trigger.py @@ -0,0 +1,435 @@ +"""Test cover triggers.""" + +from typing import Any + +import pytest + +from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_LABEL_ID, CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components import ( + TriggerStateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + +DEVICE_CLASS_TRIGGERS = [ + ("awning", "cover.awning_opened", "cover.awning_closed"), + ("blind", "cover.blind_opened", "cover.blind_closed"), + ("curtain", "cover.curtain_opened", "cover.curtain_closed"), + ("shade", "cover.shade_opened", "cover.shade_closed"), + ("shutter", "cover.shutter_opened", "cover.shutter_closed"), +] + + +@pytest.fixture +async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple cover entities associated with different targets.""" + return await target_entities(hass, "cover") + + +@pytest.mark.parametrize( + "trigger_key", + [ + trigger + for _, opened, closed in DEVICE_CLASS_TRIGGERS + for trigger in (opened, closed) + ], +) +async def test_cover_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the cover triggers are gated by the labs flag.""" + await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"}) + assert ( + "Unnamed automation failed to setup triggers and has been disabled: Trigger " + f"'{trigger_key}' requires the experimental 'New triggers and conditions' " + "feature to be enabled in Home Assistant Labs settings (feature flag: " + "'new_triggers_conditions')" + ) in caplog.text + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("cover"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + param + for device_class, opened_key, closed_key in DEVICE_CLASS_TRIGGERS + for param in ( + *parametrize_trigger_states( + trigger=opened_key, + target_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: device_class}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger=closed_key, + target_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: device_class}, + trigger_from_none=False, + ), + ) + ], +) +async def test_cover_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_covers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test cover trigger fires for cover entities with matching device_class.""" + other_entity_ids = set(target_covers["included"]) - {entity_id} + excluded_entity_ids = set(target_covers["excluded"]) - {entity_id} + + for eid in target_covers["included"]: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + for eid in excluded_entity_ids: + set_or_remove_state(hass, eid, states[0]["excluded"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {}, trigger_target_config) + + for state in states[1:]: + excluded_state = state["excluded"] + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + for excluded_entity_id in excluded_entity_ids: + set_or_remove_state(hass, excluded_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == (entities_in_target - 1) * state["count"] + service_calls.clear() + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("cover"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + param + for device_class, opened_key, closed_key in DEVICE_CLASS_TRIGGERS + for param in ( + *parametrize_trigger_states( + trigger=opened_key, + target_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: device_class}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger=closed_key, + target_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: device_class}, + trigger_from_none=False, + ), + ) + ], +) +async def test_cover_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_covers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test cover trigger fires on the first cover state change.""" + other_entity_ids = set(target_covers["included"]) - {entity_id} + excluded_entity_ids = set(target_covers["excluded"]) - {entity_id} + + for eid in target_covers["included"]: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + for eid in excluded_entity_ids: + set_or_remove_state(hass, eid, states[0]["excluded"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) + + for state in states[1:]: + excluded_state = state["excluded"] + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, excluded_state) + await hass.async_block_till_done() + for excluded_entity_id in excluded_entity_ids: + set_or_remove_state(hass, excluded_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("cover"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + param + for device_class, opened_key, closed_key in DEVICE_CLASS_TRIGGERS + for param in ( + *parametrize_trigger_states( + trigger=opened_key, + target_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: device_class}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger=closed_key, + target_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: device_class}, + trigger_from_none=False, + ), + ) + ], +) +async def test_cover_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_covers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test cover trigger fires when the last cover changes state.""" + other_entity_ids = set(target_covers["included"]) - {entity_id} + excluded_entity_ids = set(target_covers["excluded"]) - {entity_id} + + for eid in target_covers["included"]: + set_or_remove_state(hass, eid, states[0]["included"]) + await hass.async_block_till_done() + for eid in excluded_entity_ids: + set_or_remove_state(hass, eid, states[0]["excluded"]) + await hass.async_block_till_done() + + await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) + + for state in states[1:]: + excluded_state = state["excluded"] + included_state = state["included"] + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert len(service_calls) == state["count"] + for service_call in service_calls: + assert service_call.data[CONF_ENTITY_ID] == entity_id + service_calls.clear() + + for excluded_entity_id in excluded_entity_ids: + set_or_remove_state(hass, excluded_entity_id, excluded_state) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ( + "trigger_key", + "device_class", + "wrong_device_class", + "cover_initial", + "cover_initial_is_closed", + "cover_target", + "cover_target_is_closed", + ), + [ + ( + opened_key, + device_class, + "damper", + CoverState.CLOSED, + True, + CoverState.OPEN, + False, + ) + for device_class, opened_key, _ in DEVICE_CLASS_TRIGGERS + ] + + [ + ( + closed_key, + device_class, + "damper", + CoverState.OPEN, + False, + CoverState.CLOSED, + True, + ) + for device_class, _, closed_key in DEVICE_CLASS_TRIGGERS + ], +) +async def test_cover_trigger_excludes_non_matching_device_class( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + device_class: str, + wrong_device_class: str, + cover_initial: str, + cover_initial_is_closed: bool, + cover_target: str, + cover_target_is_closed: bool, +) -> None: + """Test cover trigger does not fire for entities without matching device_class.""" + entity_id_matching = "cover.test_matching" + entity_id_wrong = "cover.test_wrong" + + # Set initial states + hass.states.async_set( + entity_id_matching, + cover_initial, + {ATTR_DEVICE_CLASS: device_class, ATTR_IS_CLOSED: cover_initial_is_closed}, + ) + hass.states.async_set( + entity_id_wrong, + cover_initial, + { + ATTR_DEVICE_CLASS: wrong_device_class, + ATTR_IS_CLOSED: cover_initial_is_closed, + }, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger_key, + {}, + { + CONF_ENTITY_ID: [ + entity_id_matching, + entity_id_wrong, + ] + }, + ) + + # Matching device class changes - should trigger + hass.states.async_set( + entity_id_matching, + cover_target, + {ATTR_DEVICE_CLASS: device_class, ATTR_IS_CLOSED: cover_target_is_closed}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_matching + service_calls.clear() + + # Wrong device class changes - should NOT trigger + hass.states.async_set( + entity_id_wrong, + cover_target, + { + ATTR_DEVICE_CLASS: wrong_device_class, + ATTR_IS_CLOSED: cover_target_is_closed, + }, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 diff --git a/tests/components/door/test_trigger.py b/tests/components/door/test_trigger.py index 6602be111a2..0a0f84627a6 100644 --- a/tests/components/door/test_trigger.py +++ b/tests/components/door/test_trigger.py @@ -158,6 +158,7 @@ async def test_door_trigger_binary_sensor_behavior_any( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -382,6 +383,7 @@ async def test_door_trigger_binary_sensor_behavior_last( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -469,6 +471,7 @@ async def test_door_trigger_cover_behavior_first( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), diff --git a/tests/components/garage_door/test_trigger.py b/tests/components/garage_door/test_trigger.py index ae581eeedb9..cdb6db21f9d 100644 --- a/tests/components/garage_door/test_trigger.py +++ b/tests/components/garage_door/test_trigger.py @@ -158,6 +158,7 @@ async def test_garage_door_trigger_binary_sensor_behavior_any( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -382,6 +383,7 @@ async def test_garage_door_trigger_binary_sensor_behavior_last( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -469,6 +471,7 @@ async def test_garage_door_trigger_cover_behavior_first( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}),