diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f4ebce9f8a7..d28841eed64 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -136,6 +136,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "siren", "switch", "vacuum", + "window", } _EXPERIMENTAL_TRIGGER_PLATFORMS = { diff --git a/homeassistant/components/window/condition.py b/homeassistant/components/window/condition.py new file mode 100644 index 00000000000..35fa26378b7 --- /dev/null +++ b/homeassistant/components/window/condition.py @@ -0,0 +1,29 @@ +"""Provides conditions for windows.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_is_closed_condition, + make_cover_is_open_condition, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition + +DEVICE_CLASSES_WINDOW: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.WINDOW, + COVER_DOMAIN: CoverDeviceClass.WINDOW, +} + +CONDITIONS: dict[str, type[Condition]] = { + "is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_WINDOW), + "is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_WINDOW), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for windows.""" + return CONDITIONS diff --git a/homeassistant/components/window/conditions.yaml b/homeassistant/components/window/conditions.yaml new file mode 100644 index 00000000000..327fb2826a8 --- /dev/null +++ b/homeassistant/components/window/conditions.yaml @@ -0,0 +1,28 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_closed: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: window + - domain: cover + device_class: window + +is_open: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: window + - domain: cover + device_class: window diff --git a/homeassistant/components/window/icons.json b/homeassistant/components/window/icons.json index 0b3235bc138..b6873122170 100644 --- a/homeassistant/components/window/icons.json +++ b/homeassistant/components/window/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_closed": { + "condition": "mdi:window-closed" + }, + "is_open": { + "condition": "mdi:window-open" + } + }, "triggers": { "closed": { "trigger": "mdi:window-closed" diff --git a/homeassistant/components/window/strings.json b/homeassistant/components/window/strings.json index 14adf2062ca..b0b4d3f4aef 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,9 +1,39 @@ { "common": { + "condition_behavior_description": "How the state should match on the targeted windows.", + "condition_behavior_name": "Behavior", "trigger_behavior_description": "The behavior of the targeted windows to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_closed": { + "description": "Tests if one or more windows are closed.", + "fields": { + "behavior": { + "description": "[%key:component::window::common::condition_behavior_description%]", + "name": "[%key:component::window::common::condition_behavior_name%]" + } + }, + "name": "Window is closed" + }, + "is_open": { + "description": "Tests if one or more windows are open.", + "fields": { + "behavior": { + "description": "[%key:component::window::common::condition_behavior_description%]", + "name": "[%key:component::window::common::condition_behavior_name%]" + } + }, + "name": "Window is open" + } + }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_behavior": { "options": { "any": "Any", diff --git a/tests/components/window/test_condition.py b/tests/components/window/test_condition.py new file mode 100644 index 00000000000..5e64d10b0e6 --- /dev/null +++ b/tests/components/window/test_condition.py @@ -0,0 +1,363 @@ +"""Test window conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + 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( + "condition", + [ + "window.is_closed", + "window.is_open", + ], +) +async def test_window_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the window conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +# --- binary_sensor tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="window.is_open", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + *parametrize_condition_states_any( + condition="window.is_closed", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + ], +) +async def test_window_binary_sensor_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test window condition for binary_sensor with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="window.is_open", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + *parametrize_condition_states_all( + condition="window.is_closed", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + ], +) +async def test_window_binary_sensor_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test window condition for binary_sensor with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +# --- cover tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("cover"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="window.is_open", + target_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + *parametrize_condition_states_any( + condition="window.is_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}), + ], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + ], +) +async def test_window_cover_condition_behavior_any( + hass: HomeAssistant, + target_covers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test window condition for cover entities with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_covers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("cover"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="window.is_open", + target_states=[ + (CoverState.OPEN, {ATTR_IS_CLOSED: False}), + (CoverState.OPENING, {ATTR_IS_CLOSED: False}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (CoverState.CLOSED, {ATTR_IS_CLOSED: True}), + (CoverState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + *parametrize_condition_states_all( + condition="window.is_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}), + ], + required_filter_attributes={ATTR_DEVICE_CLASS: "window"}, + ), + ], +) +async def test_window_cover_condition_behavior_all( + hass: HomeAssistant, + target_covers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test window condition for cover entities with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_covers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +# --- Cross-domain device class exclusion test --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ( + "condition_key", + "binary_sensor_matching", + "binary_sensor_non_matching", + "cover_matching", + "cover_matching_is_closed", + "cover_non_matching", + "cover_non_matching_is_closed", + ), + [ + ( + "window.is_open", + STATE_ON, + STATE_OFF, + CoverState.OPEN, + False, + CoverState.CLOSED, + True, + ), + ( + "window.is_closed", + STATE_OFF, + STATE_ON, + CoverState.CLOSED, + True, + CoverState.OPEN, + False, + ), + ], +) +async def test_window_condition_excludes_non_window_device_class( + hass: HomeAssistant, + condition_key: str, + binary_sensor_matching: str, + binary_sensor_non_matching: str, + cover_matching: str, + cover_matching_is_closed: bool, + cover_non_matching: str, + cover_non_matching_is_closed: bool, +) -> None: + """Test window condition excludes entities without device_class window.""" + entity_id_window = "binary_sensor.test_window" + entity_id_door = "binary_sensor.test_door" + entity_id_cover_window = "cover.test_window" + entity_id_cover_garage = "cover.test_garage" + + all_entities = [ + entity_id_window, + entity_id_door, + entity_id_cover_window, + entity_id_cover_garage, + ] + + # Set matching states on all entities + hass.states.async_set( + entity_id_window, binary_sensor_matching, {ATTR_DEVICE_CLASS: "window"} + ) + hass.states.async_set( + entity_id_door, binary_sensor_matching, {ATTR_DEVICE_CLASS: "door"} + ) + hass.states.async_set( + entity_id_cover_window, + cover_matching, + {ATTR_DEVICE_CLASS: "window", ATTR_IS_CLOSED: cover_matching_is_closed}, + ) + hass.states.async_set( + entity_id_cover_garage, + cover_matching, + {ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_matching_is_closed}, + ) + await hass.async_block_till_done() + + condition_any = await create_target_condition( + hass, + condition=condition_key, + target={CONF_ENTITY_ID: all_entities}, + behavior="any", + ) + + # Matching entities in matching state - condition should be True + assert condition_any(hass) is True + + # Set matching entities to non-matching state + hass.states.async_set( + entity_id_window, binary_sensor_non_matching, {ATTR_DEVICE_CLASS: "window"} + ) + hass.states.async_set( + entity_id_cover_window, + cover_non_matching, + {ATTR_DEVICE_CLASS: "window", ATTR_IS_CLOSED: cover_non_matching_is_closed}, + ) + await hass.async_block_till_done() + + # Wrong device class entities still in matching state, but should be excluded + assert condition_any(hass) is False