From d37106a3605ce1c06bfc882666572b4f2b015497 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Mar 2026 10:59:53 +0100 Subject: [PATCH] Add gate triggers (#165228) --- CODEOWNERS | 2 + homeassistant/bootstrap.py | 1 + .../components/automation/__init__.py | 1 + homeassistant/components/gate/__init__.py | 17 + homeassistant/components/gate/icons.json | 10 + homeassistant/components/gate/manifest.json | 8 + homeassistant/components/gate/strings.json | 38 ++ homeassistant/components/gate/trigger.py | 25 ++ homeassistant/components/gate/triggers.yaml | 25 ++ script/hassfest/manifest.py | 1 + script/hassfest/quality_scale.py | 1 + tests/components/gate/__init__.py | 1 + tests/components/gate/test_trigger.py | 396 ++++++++++++++++++ tests/snapshots/test_bootstrap.ambr | 2 + 14 files changed, 528 insertions(+) create mode 100644 homeassistant/components/gate/__init__.py create mode 100644 homeassistant/components/gate/icons.json create mode 100644 homeassistant/components/gate/manifest.json create mode 100644 homeassistant/components/gate/strings.json create mode 100644 homeassistant/components/gate/trigger.py create mode 100644 homeassistant/components/gate/triggers.yaml create mode 100644 tests/components/gate/__init__.py create mode 100644 tests/components/gate/test_trigger.py diff --git a/CODEOWNERS b/CODEOWNERS index 27fe684087a..939d0adbc3c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -577,6 +577,8 @@ build.json @home-assistant/supervisor /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gardena_bluetooth/ @elupus /tests/components/gardena_bluetooth/ @elupus +/homeassistant/components/gate/ @home-assistant/core +/tests/components/gate/ @home-assistant/core /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 6985d076926..fede20375c0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -243,6 +243,7 @@ DEFAULT_INTEGRATIONS = { # Integrations providing triggers and conditions for base platforms: "door", "garage_door", + "gate", "humidity", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7017b4208f0..9792b2e41db 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -144,6 +144,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "door", "fan", "garage_door", + "gate", "humidifier", "humidity", "input_boolean", diff --git a/homeassistant/components/gate/__init__.py b/homeassistant/components/gate/__init__.py new file mode 100644 index 00000000000..b1fa802e45c --- /dev/null +++ b/homeassistant/components/gate/__init__.py @@ -0,0 +1,17 @@ +"""Integration for gate 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 = "gate" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/gate/icons.json b/homeassistant/components/gate/icons.json new file mode 100644 index 00000000000..862d38df638 --- /dev/null +++ b/homeassistant/components/gate/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "closed": { + "trigger": "mdi:gate" + }, + "opened": { + "trigger": "mdi:gate-open" + } + } +} diff --git a/homeassistant/components/gate/manifest.json b/homeassistant/components/gate/manifest.json new file mode 100644 index 00000000000..d20b1e23824 --- /dev/null +++ b/homeassistant/components/gate/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gate", + "name": "Gate", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/gate", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/gate/strings.json b/homeassistant/components/gate/strings.json new file mode 100644 index 00000000000..1852fd05a52 --- /dev/null +++ b/homeassistant/components/gate/strings.json @@ -0,0 +1,38 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted gates to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Gate", + "triggers": { + "closed": { + "description": "Triggers after one or more gates close.", + "fields": { + "behavior": { + "description": "[%key:component::gate::common::trigger_behavior_description%]", + "name": "[%key:component::gate::common::trigger_behavior_name%]" + } + }, + "name": "Gate closed" + }, + "opened": { + "description": "Triggers after one or more gates open.", + "fields": { + "behavior": { + "description": "[%key:component::gate::common::trigger_behavior_description%]", + "name": "[%key:component::gate::common::trigger_behavior_name%]" + } + }, + "name": "Gate opened" + } + } +} diff --git a/homeassistant/components/gate/trigger.py b/homeassistant/components/gate/trigger.py new file mode 100644 index 00000000000..4f8d6ffa53c --- /dev/null +++ b/homeassistant/components/gate/trigger.py @@ -0,0 +1,25 @@ +"""Provides triggers for gates.""" + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_closed_trigger, + make_cover_opened_trigger, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger + +DEVICE_CLASSES_GATE: dict[str, str] = { + COVER_DOMAIN: CoverDeviceClass.GATE, +} + + +TRIGGERS: dict[str, type[Trigger]] = { + "opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE), + "closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for gates.""" + return TRIGGERS diff --git a/homeassistant/components/gate/triggers.yaml b/homeassistant/components/gate/triggers.yaml new file mode 100644 index 00000000000..b50ae440c36 --- /dev/null +++ b/homeassistant/components/gate/triggers.yaml @@ -0,0 +1,25 @@ +.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: cover + device_class: gate + +opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: gate diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index a59f24c97b8..538524696c1 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -77,6 +77,7 @@ NO_IOT_CLASS = [ "file_upload", "frontend", "garage_door", + "gate", "hardkernel", "hardware", "history", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 2004413c9d9..0681953dd3f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2112,6 +2112,7 @@ NO_QUALITY_SCALE = [ "file_upload", "frontend", "garage_door", + "gate", "hardkernel", "hardware", "history", diff --git a/tests/components/gate/__init__.py b/tests/components/gate/__init__.py new file mode 100644 index 00000000000..f62f22f5a95 --- /dev/null +++ b/tests/components/gate/__init__.py @@ -0,0 +1 @@ +"""Tests for the gate integration.""" diff --git a/tests/components/gate/test_trigger.py b/tests/components/gate/test_trigger.py new file mode 100644 index 00000000000..8d694125e1a --- /dev/null +++ b/tests/components/gate/test_trigger.py @@ -0,0 +1,396 @@ +"""Test gate 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 +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_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", + [ + "gate.opened", + "gate.closed", + ], +) +async def test_gate_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the gate 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"), + [ + *parametrize_trigger_states( + trigger="gate.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: "gate"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="gate.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}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: "gate"}, + trigger_from_none=False, + ), + ], +) +async def test_gate_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 gate trigger fires for cover entities with device_class gate.""" + 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"), + [ + *parametrize_trigger_states( + trigger="gate.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: "gate"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="gate.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}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: "gate"}, + trigger_from_none=False, + ), + ], +) +async def test_gate_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 gate 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="gate.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: "gate"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="gate.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}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: None}), + (CoverState.OPEN, {}), + ], + additional_attributes={ATTR_DEVICE_CLASS: "gate"}, + trigger_from_none=False, + ), + ], +) +async def test_gate_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 gate 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", + "cover_initial", + "cover_initial_is_closed", + "cover_target", + "cover_target_is_closed", + ), + [ + ( + "gate.opened", + CoverState.CLOSED, + True, + CoverState.OPEN, + False, + ), + ( + "gate.closed", + CoverState.OPEN, + False, + CoverState.CLOSED, + True, + ), + ], +) +async def test_gate_trigger_excludes_non_gate_device_class( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + cover_initial: str, + cover_initial_is_closed: bool, + cover_target: str, + cover_target_is_closed: bool, +) -> None: + """Test gate trigger does not fire for entities without device_class gate.""" + entity_id_cover_gate = "cover.test_gate" + entity_id_cover_garage = "cover.test_garage" + + # Set initial states + hass.states.async_set( + entity_id_cover_gate, + cover_initial, + {ATTR_DEVICE_CLASS: "gate", ATTR_IS_CLOSED: cover_initial_is_closed}, + ) + hass.states.async_set( + entity_id_cover_garage, + cover_initial, + {ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_initial_is_closed}, + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger_key, + {}, + { + CONF_ENTITY_ID: [ + entity_id_cover_gate, + entity_id_cover_garage, + ] + }, + ) + + # Gate cover changes - should trigger + hass.states.async_set( + entity_id_cover_gate, + cover_target, + {ATTR_DEVICE_CLASS: "gate", 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_gate + service_calls.clear() + + # Garage cover changes - should NOT trigger (wrong device class) + hass.states.async_set( + entity_id_cover_garage, + cover_target, + {ATTR_DEVICE_CLASS: "garage", 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 ea899a9e27b..93faf22bfdf 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -37,6 +37,7 @@ 'file_upload', 'frontend', 'garage_door', + 'gate', 'geo_location', 'group', 'hardware', @@ -138,6 +139,7 @@ 'file_upload', 'frontend', 'garage_door', + 'gate', 'geo_location', 'hardware', 'homeassistant',