From a5d03505601176a9d7609baf481b11531bf46cf1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Mar 2026 11:42:09 +0100 Subject: [PATCH] Add garage_door triggers (#165144) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 2 + homeassistant/bootstrap.py | 1 + .../components/automation/__init__.py | 1 + homeassistant/components/cover/__init__.py | 82 +-- homeassistant/components/cover/const.py | 46 ++ homeassistant/components/cover/trigger.py | 73 ++ homeassistant/components/door/trigger.py | 81 +-- .../components/garage_door/__init__.py | 15 + .../components/garage_door/icons.json | 10 + .../components/garage_door/manifest.json | 8 + .../components/garage_door/strings.json | 38 + .../components/garage_door/trigger.py | 42 ++ .../components/garage_door/triggers.yaml | 29 + script/hassfest/manifest.py | 1 + script/hassfest/quality_scale.py | 1 + tests/components/door/test_trigger.py | 16 +- tests/components/garage_door/__init__.py | 1 + tests/components/garage_door/test_trigger.py | 647 ++++++++++++++++++ tests/snapshots/test_bootstrap.ambr | 2 + 19 files changed, 973 insertions(+), 123 deletions(-) create mode 100644 homeassistant/components/cover/trigger.py create mode 100644 homeassistant/components/garage_door/__init__.py create mode 100644 homeassistant/components/garage_door/icons.json create mode 100644 homeassistant/components/garage_door/manifest.json create mode 100644 homeassistant/components/garage_door/strings.json create mode 100644 homeassistant/components/garage_door/trigger.py create mode 100644 homeassistant/components/garage_door/triggers.yaml create mode 100644 tests/components/garage_door/__init__.py create mode 100644 tests/components/garage_door/test_trigger.py diff --git a/CODEOWNERS b/CODEOWNERS index 2e92234a2e5..99489cc420e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -569,6 +569,8 @@ build.json @home-assistant/supervisor /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/fyta/ @dontinelli /tests/components/fyta/ @dontinelli +/homeassistant/components/garage_door/ @home-assistant/core +/tests/components/garage_door/ @home-assistant/core /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gardena_bluetooth/ @elupus diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 89f7e4f575b..9285430755e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -242,6 +242,7 @@ DEFAULT_INTEGRATIONS = { # # Integrations providing triggers and conditions for base platforms: "door", + "garage_door", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { # These integrations are set up if recovery mode is activated. diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 0dbf5fef76f..3b42a9bb6a7 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -144,6 +144,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "device_tracker", "door", "fan", + "garage_door", "humidifier", "lawn_mower", "light", diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 4d5b5d0a05b..1dbf972a26f 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import timedelta -from enum import IntFlag, StrEnum import functools as ft import logging from typing import Any, final @@ -33,7 +32,20 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401 +from .const import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_IS_CLOSED, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + INTENT_CLOSE_COVER, + INTENT_OPEN_COVER, + CoverDeviceClass, + CoverEntityFeature, + CoverState, +) +from .trigger import CoverClosedTriggerBase, CoverOpenedTriggerBase _LOGGER = logging.getLogger(__name__) @@ -43,57 +55,33 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=15) - -class CoverState(StrEnum): - """State of Cover entities.""" - - CLOSED = "closed" - CLOSING = "closing" - OPEN = "open" - OPENING = "opening" - - -class CoverDeviceClass(StrEnum): - """Device class for cover.""" - - # Refer to the cover dev docs for device class descriptions - AWNING = "awning" - BLIND = "blind" - CURTAIN = "curtain" - DAMPER = "damper" - DOOR = "door" - GARAGE = "garage" - GATE = "gate" - SHADE = "shade" - SHUTTER = "shutter" - WINDOW = "window" - - DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] - # mypy: disallow-any-generics -class CoverEntityFeature(IntFlag): - """Supported features of the cover entity.""" - - OPEN = 1 - CLOSE = 2 - SET_POSITION = 4 - STOP = 8 - OPEN_TILT = 16 - CLOSE_TILT = 32 - STOP_TILT = 64 - SET_TILT_POSITION = 128 - - -ATTR_CURRENT_POSITION = "current_position" -ATTR_CURRENT_TILT_POSITION = "current_tilt_position" -ATTR_IS_CLOSED = "is_closed" -ATTR_POSITION = "position" -ATTR_TILT_POSITION = "tilt_position" +__all__ = [ + "ATTR_CURRENT_POSITION", + "ATTR_CURRENT_TILT_POSITION", + "ATTR_IS_CLOSED", + "ATTR_POSITION", + "ATTR_TILT_POSITION", + "DEVICE_CLASSES", + "DEVICE_CLASSES_SCHEMA", + "DOMAIN", + "INTENT_CLOSE_COVER", + "INTENT_OPEN_COVER", + "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", + "CoverClosedTriggerBase", + "CoverDeviceClass", + "CoverEntity", + "CoverEntityDescription", + "CoverEntityFeature", + "CoverOpenedTriggerBase", + "CoverState", +] @bind_hass diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py index e9bbf81e5f5..c73b32998c5 100644 --- a/homeassistant/components/cover/const.py +++ b/homeassistant/components/cover/const.py @@ -1,6 +1,52 @@ """Constants for cover entity platform.""" +from enum import IntFlag, StrEnum + DOMAIN = "cover" +ATTR_CURRENT_POSITION = "current_position" +ATTR_CURRENT_TILT_POSITION = "current_tilt_position" +ATTR_IS_CLOSED = "is_closed" +ATTR_POSITION = "position" +ATTR_TILT_POSITION = "tilt_position" + INTENT_OPEN_COVER = "HassOpenCover" INTENT_CLOSE_COVER = "HassCloseCover" + + +class CoverEntityFeature(IntFlag): + """Supported features of the cover entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + OPEN_TILT = 16 + CLOSE_TILT = 32 + STOP_TILT = 64 + SET_TILT_POSITION = 128 + + +class CoverState(StrEnum): + """State of Cover entities.""" + + CLOSED = "closed" + CLOSING = "closing" + OPEN = "open" + OPENING = "opening" + + +class CoverDeviceClass(StrEnum): + """Device class for cover.""" + + # Refer to the cover dev docs for device class descriptions + AWNING = "awning" + BLIND = "blind" + CURTAIN = "curtain" + DAMPER = "damper" + DOOR = "door" + GARAGE = "garage" + GATE = "gate" + SHADE = "shade" + SHUTTER = "shutter" + WINDOW = "window" diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py new file mode 100644 index 00000000000..930f54c2aaa --- /dev/null +++ b/homeassistant/components/cover/trigger.py @@ -0,0 +1,73 @@ +"""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.typing import UNDEFINED, UndefinedType + +from . import ATTR_IS_CLOSED, DOMAIN + + +def get_device_class_or_undefined( + hass: HomeAssistant, entity_id: str +) -> str | None | UndefinedType: + """Get the device class of an entity or UNDEFINED if not found.""" + try: + return get_device_class(hass, entity_id) + except HomeAssistantError: + return 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] + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities by cover device class.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if get_device_class_or_undefined(self._hass, entity_id) + == self._device_classes[split_entity_id(entity_id)[0]] + } + + def is_valid_state(self, state: State) -> bool: + """Check if the state matches the target cover state.""" + if split_entity_id(state.entity_id)[0] == DOMAIN: + return ( + state.attributes.get(ATTR_IS_CLOSED) + == self._cover_is_closed_target_value + ) + return state.state == self._binary_sensor_target_state + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the transition is valid for a cover state change.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + if split_entity_id(from_state.entity_id)[0] == DOMAIN: + if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None: + return False + return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return] + return from_state.state != to_state.state + + +class CoverOpenedTriggerBase(CoverTriggerBase): + """Base trigger for cover opened state changes.""" + + _binary_sensor_target_state = STATE_ON + _cover_is_closed_target_value = False + + +class CoverClosedTriggerBase(CoverTriggerBase): + """Base trigger for cover closed state changes.""" + + _binary_sensor_target_state = STATE_OFF + _cover_is_closed_target_value = True diff --git a/homeassistant/components/door/trigger.py b/homeassistant/components/door/trigger.py index e4c73f0dbdd..2c6f1b8aab9 100644 --- a/homeassistant/components/door/trigger.py +++ b/homeassistant/components/door/trigger.py @@ -1,75 +1,34 @@ """Provides triggers for doors.""" -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.cover import ATTR_IS_CLOSED, DOMAIN as COVER_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, Trigger -from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverClosedTriggerBase, + CoverDeviceClass, + CoverOpenedTriggerBase, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger -DEVICE_CLASS_DOOR = "door" +DEVICE_CLASSES_DOOR: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR, + COVER_DOMAIN: CoverDeviceClass.DOOR, +} -def get_device_class_or_undefined( - hass: HomeAssistant, entity_id: str -) -> str | None | UndefinedType: - """Get the device class of an entity or UNDEFINED if not found.""" - try: - return get_device_class(hass, entity_id) - except HomeAssistantError: - return UNDEFINED - - -class DoorTriggerBase(EntityTriggerBase): - """Base trigger for door state changes.""" - - _domains = {BINARY_SENSOR_DOMAIN, COVER_DOMAIN} - _binary_sensor_target_state: str - _cover_is_closed_target_value: bool - - def entity_filter(self, entities: set[str]) -> set[str]: - """Filter entities by door device class.""" - entities = super().entity_filter(entities) - return { - entity_id - for entity_id in entities - if get_device_class_or_undefined(self._hass, entity_id) == DEVICE_CLASS_DOOR - } - - def is_valid_state(self, state: State) -> bool: - """Check if the state matches the target door state.""" - if split_entity_id(state.entity_id)[0] == COVER_DOMAIN: - return ( - state.attributes.get(ATTR_IS_CLOSED) - == self._cover_is_closed_target_value - ) - return state.state == self._binary_sensor_target_state - - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the transition is valid for a door state change.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - if split_entity_id(from_state.entity_id)[0] == COVER_DOMAIN: - if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None: - return False - return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) - return from_state.state != to_state.state - - -class DoorOpenedTrigger(DoorTriggerBase): +class DoorOpenedTrigger(CoverOpenedTriggerBase): """Trigger for door opened state changes.""" - _binary_sensor_target_state = STATE_ON - _cover_is_closed_target_value = False + _device_classes = DEVICE_CLASSES_DOOR -class DoorClosedTrigger(DoorTriggerBase): +class DoorClosedTrigger(CoverClosedTriggerBase): """Trigger for door closed state changes.""" - _binary_sensor_target_state = STATE_OFF - _cover_is_closed_target_value = True + _device_classes = DEVICE_CLASSES_DOOR TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/garage_door/__init__.py b/homeassistant/components/garage_door/__init__.py new file mode 100644 index 00000000000..ef353a5d31b --- /dev/null +++ b/homeassistant/components/garage_door/__init__.py @@ -0,0 +1,15 @@ +"""Integration for garage door triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "garage_door" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/garage_door/icons.json b/homeassistant/components/garage_door/icons.json new file mode 100644 index 00000000000..f1a608065a1 --- /dev/null +++ b/homeassistant/components/garage_door/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "closed": { + "trigger": "mdi:garage" + }, + "opened": { + "trigger": "mdi:garage-open" + } + } +} diff --git a/homeassistant/components/garage_door/manifest.json b/homeassistant/components/garage_door/manifest.json new file mode 100644 index 00000000000..f9ea106efcb --- /dev/null +++ b/homeassistant/components/garage_door/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "garage_door", + "name": "Garage door", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/garage_door", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/garage_door/strings.json b/homeassistant/components/garage_door/strings.json new file mode 100644 index 00000000000..68eed282fd1 --- /dev/null +++ b/homeassistant/components/garage_door/strings.json @@ -0,0 +1,38 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Garage door", + "triggers": { + "closed": { + "description": "Triggers after one or more garage doors close.", + "fields": { + "behavior": { + "description": "[%key:component::garage_door::common::trigger_behavior_description%]", + "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + } + }, + "name": "Garage door closed" + }, + "opened": { + "description": "Triggers after one or more garage doors open.", + "fields": { + "behavior": { + "description": "[%key:component::garage_door::common::trigger_behavior_description%]", + "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + } + }, + "name": "Garage door opened" + } + } +} diff --git a/homeassistant/components/garage_door/trigger.py b/homeassistant/components/garage_door/trigger.py new file mode 100644 index 00000000000..31a0bf04458 --- /dev/null +++ b/homeassistant/components/garage_door/trigger.py @@ -0,0 +1,42 @@ +"""Provides triggers for garage doors.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverClosedTriggerBase, + CoverDeviceClass, + CoverOpenedTriggerBase, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger + +DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR, + COVER_DOMAIN: CoverDeviceClass.GARAGE, +} + + +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, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for garage doors.""" + return TRIGGERS diff --git a/homeassistant/components/garage_door/triggers.yaml b/homeassistant/components/garage_door/triggers.yaml new file mode 100644 index 00000000000..5a36582d0de --- /dev/null +++ b/homeassistant/components/garage_door/triggers.yaml @@ -0,0 +1,29 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: garage_door + - domain: cover + device_class: garage + +opened: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: garage_door + - domain: cover + device_class: garage diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index d4a239e5537..420b1b61283 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -76,6 +76,7 @@ NO_IOT_CLASS = [ "ffmpeg", "file_upload", "frontend", + "garage_door", "hardkernel", "hardware", "history", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0a673aa4cb7..6c2f91baa68 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2111,6 +2111,7 @@ NO_QUALITY_SCALE = [ "ffmpeg", "file_upload", "frontend", + "garage_door", "hardkernel", "hardware", "history", diff --git a/tests/components/door/test_trigger.py b/tests/components/door/test_trigger.py index 74646744351..6602be111a2 100644 --- a/tests/components/door/test_trigger.py +++ b/tests/components/door/test_trigger.py @@ -4,9 +4,7 @@ from typing import Any import pytest -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState -from homeassistant.components.door.trigger import DEVICE_CLASS_DOOR +from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_LABEL_ID, @@ -143,7 +141,6 @@ async def test_door_trigger_binary_sensor_behavior_any( other_states=[ (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), - (CoverState.OPEN, {ATTR_IS_CLOSED: True}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -161,7 +158,6 @@ async def test_door_trigger_binary_sensor_behavior_any( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), - (CoverState.CLOSED, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -369,7 +365,6 @@ async def test_door_trigger_binary_sensor_behavior_last( other_states=[ (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), - (CoverState.OPEN, {ATTR_IS_CLOSED: True}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -387,7 +382,6 @@ async def test_door_trigger_binary_sensor_behavior_last( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), - (CoverState.CLOSED, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -458,7 +452,6 @@ async def test_door_trigger_cover_behavior_first( other_states=[ (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), - (CoverState.OPEN, {ATTR_IS_CLOSED: True}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -476,7 +469,6 @@ async def test_door_trigger_cover_behavior_first( other_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: False}), (CoverState.OPENING, {ATTR_IS_CLOSED: False}), - (CoverState.CLOSED, {ATTR_IS_CLOSED: False}), ], extra_invalid_states=[ (CoverState.OPEN, {ATTR_IS_CLOSED: None}), @@ -649,9 +641,3 @@ async def test_door_trigger_excludes_non_door_device_class( ) await hass.async_block_till_done() assert len(service_calls) == 0 - - -def test_door_device_class() -> None: - """Test the door trigger device class.""" - assert BinarySensorDeviceClass.DOOR == DEVICE_CLASS_DOOR - assert CoverDeviceClass.DOOR == DEVICE_CLASS_DOOR diff --git a/tests/components/garage_door/__init__.py b/tests/components/garage_door/__init__.py new file mode 100644 index 00000000000..80cfe395806 --- /dev/null +++ b/tests/components/garage_door/__init__.py @@ -0,0 +1 @@ +"""Tests for the garage_door integration.""" diff --git a/tests/components/garage_door/test_trigger.py b/tests/components/garage_door/test_trigger.py new file mode 100644 index 00000000000..ae581eeedb9 --- /dev/null +++ b/tests/components/garage_door/test_trigger.py @@ -0,0 +1,647 @@ +"""Test garage door trigger.""" + +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, + STATE_OFF, + STATE_ON, +) +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, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@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", + [ + "garage_door.opened", + "garage_door.closed", + ], +) +async def test_garage_door_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the garage door 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("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="garage_door.opened", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "garage_door"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="garage_door.closed", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "garage_door"}, + trigger_from_none=False, + ), + ], +) +async def test_garage_door_trigger_binary_sensor_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: 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 garage door trigger fires for binary_sensor entities with device_class garage_door.""" + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + for eid in target_binary_sensors["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"), + [ + *parametrize_trigger_states( + trigger="garage_door.opened", + 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: "garage"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="garage_door.closed", + 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}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: "garage"}, + trigger_from_none=False, + ), + ], +) +async def test_garage_door_trigger_cover_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 garage door trigger fires for cover entities with device_class garage.""" + 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("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="garage_door.opened", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "garage_door"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="garage_door.closed", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "garage_door"}, + trigger_from_none=False, + ), + ], +) +async def test_garage_door_trigger_binary_sensor_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: 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 garage door trigger fires on the first binary_sensor state change.""" + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + for eid in target_binary_sensors["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("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="garage_door.opened", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "garage_door"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="garage_door.closed", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "garage_door"}, + trigger_from_none=False, + ), + ], +) +async def test_garage_door_trigger_binary_sensor_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: 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 garage door trigger fires when the last binary_sensor changes state.""" + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + for eid in target_binary_sensors["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_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("cover"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="garage_door.opened", + 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: "garage"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="garage_door.closed", + 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}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: "garage"}, + trigger_from_none=False, + ), + ], +) +async def test_garage_door_trigger_cover_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 garage door 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"), + [ + *parametrize_trigger_states( + trigger="garage_door.opened", + 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: "garage"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="garage_door.closed", + 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}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: "garage"}, + trigger_from_none=False, + ), + ], +) +async def test_garage_door_trigger_cover_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 garage door 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", + "binary_sensor_initial", + "binary_sensor_target", + "cover_initial", + "cover_initial_is_closed", + "cover_target", + "cover_target_is_closed", + ), + [ + ( + "garage_door.opened", + STATE_OFF, + STATE_ON, + CoverState.CLOSED, + True, + CoverState.OPEN, + False, + ), + ( + "garage_door.closed", + STATE_ON, + STATE_OFF, + CoverState.OPEN, + False, + CoverState.CLOSED, + True, + ), + ], +) +async def test_garage_door_trigger_excludes_non_garage_door_device_class( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + binary_sensor_initial: str, + binary_sensor_target: str, + cover_initial: str, + cover_initial_is_closed: bool, + cover_target: str, + cover_target_is_closed: bool, +) -> None: + """Test garage door trigger does not fire for entities without device_class garage_door.""" + entity_id_garage_door = "binary_sensor.test_garage_door" + entity_id_door = "binary_sensor.test_door" + entity_id_cover_garage_door = "cover.test_garage_door" + entity_id_cover_door = "cover.test_door" + + # Set initial states + hass.states.async_set( + entity_id_garage_door, + binary_sensor_initial, + {ATTR_DEVICE_CLASS: "garage_door"}, + ) + hass.states.async_set( + entity_id_door, binary_sensor_initial, {ATTR_DEVICE_CLASS: "door"} + ) + hass.states.async_set( + entity_id_cover_garage_door, + cover_initial, + {ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_initial_is_closed}, + ) + hass.states.async_set( + entity_id_cover_door, + cover_initial, + {ATTR_DEVICE_CLASS: "door", ATTR_IS_CLOSED: cover_initial_is_closed}, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger_key, + {}, + { + CONF_ENTITY_ID: [ + entity_id_garage_door, + entity_id_door, + entity_id_cover_garage_door, + entity_id_cover_door, + ] + }, + ) + + # Garage door binary_sensor changes - should trigger + hass.states.async_set( + entity_id_garage_door, + binary_sensor_target, + {ATTR_DEVICE_CLASS: "garage_door"}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_garage_door + service_calls.clear() + + # Door binary_sensor changes - should NOT trigger (wrong device class) + hass.states.async_set( + entity_id_door, binary_sensor_target, {ATTR_DEVICE_CLASS: "door"} + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Cover garage door changes - should trigger + hass.states.async_set( + entity_id_cover_garage_door, + cover_target, + {ATTR_DEVICE_CLASS: "garage", 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_cover_garage_door + service_calls.clear() + + # Door cover changes - should NOT trigger (wrong device class) + hass.states.async_set( + entity_id_cover_door, + cover_target, + {ATTR_DEVICE_CLASS: "door", ATTR_IS_CLOSED: cover_target_is_closed}, + ) + await hass.async_block_till_done() + assert len(service_calls) == 0 diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index c6258418d04..6cb765abd5b 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -36,6 +36,7 @@ 'ffmpeg', 'file_upload', 'frontend', + 'garage_door', 'geo_location', 'group', 'hardware', @@ -135,6 +136,7 @@ 'ffmpeg', 'file_upload', 'frontend', + 'garage_door', 'geo_location', 'hardware', 'homeassistant',