From e63242e4652847255281d00dfabf4e27aeff2168 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Dec 2025 16:37:02 +0100 Subject: [PATCH] Add occupancy binary sensor triggers (#157631) --- .../components/automation/__init__.py | 1 + .../components/binary_sensor/icons.json | 8 + .../components/binary_sensor/strings.json | 37 ++- .../components/binary_sensor/trigger.py | 67 +++++ .../components/binary_sensor/triggers.yaml | 25 ++ tests/components/__init__.py | 91 +++++- .../alarm_control_panel/test_trigger.py | 23 +- .../assist_satellite/test_trigger.py | 23 +- .../components/binary_sensor/test_trigger.py | 278 ++++++++++++++++++ tests/components/climate/test_trigger.py | 44 +-- tests/components/fan/test_trigger.py | 23 +- tests/components/lawn_mower/test_trigger.py | 23 +- tests/components/light/test_condition.py | 4 +- tests/components/light/test_trigger.py | 23 +- tests/components/media_player/test_trigger.py | 23 +- tests/components/text/test_trigger.py | 50 ++-- tests/components/vacuum/test_trigger.py | 23 +- tests/helpers/test_icon.py | 4 +- tests/helpers/test_trigger.py | 6 + 19 files changed, 646 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/binary_sensor/trigger.py create mode 100644 homeassistant/components/binary_sensor/triggers.yaml create mode 100644 tests/components/binary_sensor/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 31997287ddb..149ea8b4b07 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { _EXPERIMENTAL_TRIGGER_PLATFORMS = { "alarm_control_panel", "assist_satellite", + "binary_sensor", "climate", "cover", "fan", diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json index 5bd1c338921..a457fa667ed 100644 --- a/homeassistant/components/binary_sensor/icons.json +++ b/homeassistant/components/binary_sensor/icons.json @@ -174,5 +174,13 @@ "on": "mdi:window-open" } } + }, + "triggers": { + "occupancy_cleared": { + "trigger": "mdi:home-outline" + }, + "occupancy_detected": { + "trigger": "mdi:home" + } } } diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 08d16fd0396..cf6e1ee6320 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description_presence": "The behavior of the targeted presence sensors to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "condition_type": { "is_bat_low": "{entity_name} battery is low", @@ -317,5 +321,36 @@ } } }, - "title": "Binary sensor" + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Binary sensor", + "triggers": { + "occupancy_cleared": { + "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]", + "name": "[%key:component::binary_sensor::common::trigger_behavior_name%]" + } + }, + "name": "Occupancy cleared" + }, + "occupancy_detected": { + "description": "Triggers after one ore more occupancy sensors start detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]", + "name": "[%key:component::binary_sensor::common::trigger_behavior_name%]" + } + }, + "name": "Occupancy detected" + } + } } diff --git a/homeassistant/components/binary_sensor/trigger.py b/homeassistant/components/binary_sensor/trigger.py new file mode 100644 index 00000000000..e7b614ebdf1 --- /dev/null +++ b/homeassistant/components/binary_sensor/trigger.py @@ -0,0 +1,67 @@ +"""Provides triggers for binary sensors.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger +from homeassistant.helpers.typing import UNDEFINED, UndefinedType + +from . import DOMAIN, BinarySensorDeviceClass + + +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 BinarySensorOnOffTrigger(EntityStateTriggerBase): + """Class for binary sensor on/off triggers.""" + + _device_class: BinarySensorDeviceClass | None + _domain: str = DOMAIN + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + 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_class + } + + +def make_binary_sensor_trigger( + device_class: BinarySensorDeviceClass | None, + to_state: str, +) -> type[BinarySensorOnOffTrigger]: + """Create an entity state trigger class.""" + + class CustomTrigger(BinarySensorOnOffTrigger): + """Trigger for entity state changes.""" + + _device_class = device_class + _to_state = to_state + + return CustomTrigger + + +TRIGGERS: dict[str, type[Trigger]] = { + "occupancy_detected": make_binary_sensor_trigger( + BinarySensorDeviceClass.OCCUPANCY, STATE_ON + ), + "occupancy_cleared": make_binary_sensor_trigger( + BinarySensorDeviceClass.OCCUPANCY, STATE_OFF + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for binary sensors.""" + return TRIGGERS diff --git a/homeassistant/components/binary_sensor/triggers.yaml b/homeassistant/components/binary_sensor/triggers.yaml new file mode 100644 index 00000000000..1da7ce169ee --- /dev/null +++ b/homeassistant/components/binary_sensor/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 + +occupancy_cleared: + fields: *trigger_common_fields + target: + entity: + domain: binary_sensor + device_class: presence + +occupancy_detected: + fields: *trigger_common_fields + target: + entity: + domain: binary_sensor + device_class: presence diff --git a/tests/components/__init__.py b/tests/components/__init__.py index 703ee3b8420..2010c3890fa 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -29,8 +29,15 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, mock_device_registry -async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: - """Create multiple entities associated with different targets.""" +async def target_entities( + hass: HomeAssistant, domain: str +) -> tuple[list[str], list[str]]: + """Create multiple entities associated with different targets. + + Returns a dict with the following keys: + - included: List of entity_ids meant to be targeted. + - excluded: List of entity_ids not meant to be targeted. + """ await async_setup_component(hass, domain, {}) config_entry = MockConfigEntry(domain="test") @@ -55,7 +62,7 @@ async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: mock_device_registry(hass, {device.id: device}) entity_reg = er.async_get(hass) - # Entity associated with area + # Entities associated with area entity_area = entity_reg.async_get_or_create( domain=domain, platform="test", @@ -63,8 +70,15 @@ async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: suggested_object_id=f"area_{domain}", ) entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id) + entity_area_excluded = entity_reg.async_get_or_create( + domain=domain, + platform="test", + unique_id=f"{domain}_area_excluded", + suggested_object_id=f"area_{domain}_excluded", + ) + entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id) - # Entity associated with device + # Entities associated with device entity_reg.async_get_or_create( domain=domain, platform="test", @@ -72,8 +86,15 @@ async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: suggested_object_id=f"device_{domain}", device_id=device.id, ) + entity_reg.async_get_or_create( + domain=domain, + platform="test", + unique_id=f"{domain}_device_excluded", + suggested_object_id=f"device_{domain}_excluded", + device_id=device.id, + ) - # Entity associated with label + # Entities associated with label entity_label = entity_reg.async_get_or_create( domain=domain, platform="test", @@ -81,14 +102,31 @@ async def target_entities(hass: HomeAssistant, domain: str) -> list[str]: suggested_object_id=f"label_{domain}", ) entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id}) + entity_label_excluded = entity_reg.async_get_or_create( + domain=domain, + platform="test", + unique_id=f"{domain}_label_excluded", + suggested_object_id=f"label_{domain}_excluded", + ) + entity_reg.async_update_entity( + entity_label_excluded.entity_id, labels={label.label_id} + ) # Return all available entities - return [ - f"{domain}.standalone_{domain}", - f"{domain}.label_{domain}", - f"{domain}.area_{domain}", - f"{domain}.device_{domain}", - ] + return { + "included": [ + f"{domain}.standalone_{domain}", + f"{domain}.label_{domain}", + f"{domain}.area_{domain}", + f"{domain}.device_{domain}", + ], + "excluded": [ + f"{domain}.standalone_{domain}_excluded", + f"{domain}.label_{domain}_excluded", + f"{domain}.area_{domain}_excluded", + f"{domain}.device_{domain}_excluded", + ], + } def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]: @@ -112,11 +150,18 @@ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]: ] -class StateDescription(TypedDict): +class _StateDescription(TypedDict): """Test state and expected service call count.""" state: str | None attributes: dict + + +class StateDescription(TypedDict): + """Test state and expected service call count.""" + + included: _StateDescription + excluded: _StateDescription count: int @@ -147,10 +192,26 @@ def parametrize_trigger_states( ) -> dict: """Return (state, attributes) dict.""" if isinstance(state, str) or state is None: - return {"state": state, "attributes": additional_attributes, "count": count} + return { + "included": { + "state": state, + "attributes": additional_attributes, + }, + "excluded": { + "state": state, + "attributes": {}, + }, + "count": count, + } return { - "state": state[0], - "attributes": state[1] | additional_attributes, + "included": { + "state": state[0], + "attributes": state[1] | additional_attributes, + }, + "excluded": { + "state": state[0], + "attributes": state[1], + }, "count": count, } diff --git a/tests/components/alarm_control_panel/test_trigger.py b/tests/components/alarm_control_panel/test_trigger.py index e339a0da9b8..46f9aee7db0 100644 --- a/tests/components/alarm_control_panel/test_trigger.py +++ b/tests/components/alarm_control_panel/test_trigger.py @@ -42,7 +42,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]: """Create multiple alarm control panel entities associated with different targets.""" - return await target_entities(hass, "alarm_control_panel") + return (await target_entities(hass, "alarm_control_panel"))["included"] @pytest.mark.parametrize( @@ -160,13 +160,14 @@ async def test_alarm_control_panel_state_trigger_behavior_any( # Set all alarm control panels, including the tested one, to the initial state for eid in target_alarm_control_panels: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -175,7 +176,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any( # Check if changing other alarm control panels also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -271,13 +272,14 @@ async def test_alarm_control_panel_state_trigger_behavior_first( # Set all alarm control panels, including the tested one, to the initial state for eid in target_alarm_control_panels: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -286,7 +288,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first( # Triggering other alarm control panels should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -381,18 +383,19 @@ async def test_alarm_control_panel_state_trigger_behavior_last( # Set all alarm control panels, including the tested one, to the initial state for eid in target_alarm_control_panels: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/assist_satellite/test_trigger.py b/tests/components/assist_satellite/test_trigger.py index e6a014192dc..c6c50f6f7bd 100644 --- a/tests/components/assist_satellite/test_trigger.py +++ b/tests/components/assist_satellite/test_trigger.py @@ -39,7 +39,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_assist_satellites(hass: HomeAssistant) -> list[str]: """Create multiple assist satellite entities associated with different targets.""" - return await target_entities(hass, "assist_satellite") + return (await target_entities(hass, "assist_satellite"))["included"] @pytest.mark.parametrize( @@ -111,13 +111,14 @@ async def test_assist_satellite_state_trigger_behavior_any( # Set all assist satellites, including the tested one, to the initial state for eid in target_assist_satellites: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -126,7 +127,7 @@ async def test_assist_satellite_state_trigger_behavior_any( # Check if changing other assist satellites also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -179,13 +180,14 @@ async def test_assist_satellite_state_trigger_behavior_first( # Set all assist satellites, including the tested one, to the initial state for eid in target_assist_satellites: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -194,7 +196,7 @@ async def test_assist_satellite_state_trigger_behavior_first( # Triggering other assist satellites should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -246,18 +248,19 @@ async def test_assist_satellite_state_trigger_behavior_last( # Set all assist satellites, including the tested one, to the initial state for eid in target_assist_satellites: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/binary_sensor/test_trigger.py b/tests/components/binary_sensor/test_trigger.py new file mode 100644 index 00000000000..511892c3ac3 --- /dev/null +++ b/tests/components/binary_sensor/test_trigger.py @@ -0,0 +1,278 @@ +"""Test binary sensor trigger.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_LABEL_ID, + CONF_ENTITY_ID, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from tests.components import ( + StateDescription, + arm_trigger, + parametrize_target_entities, + parametrize_trigger_states, + set_or_remove_state, + target_entities, +) + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + +@pytest.fixture(name="enable_experimental_triggers_conditions") +def enable_experimental_triggers_conditions() -> Generator[None]: + """Enable experimental triggers and conditions.""" + with patch( + "homeassistant.components.labs.async_is_preview_feature_enabled", + return_value=True, + ): + yield + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> tuple[list[str], list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + "trigger_key", + [ + "binary_sensor.occupancy_detected", + "binary_sensor.occupancy_cleared", + ], +) +async def test_binary_sensor_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the binary sensor 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_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="binary_sensor.occupancy_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="binary_sensor.occupancy_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + ], +) +async def test_binary_sensor_state_attribute_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: dict[list[str], list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the binary sensor state trigger fires when any binary sensor state changes to a specific state.""" + await async_setup_component(hass, "binary_sensor", {}) + + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + # Set all binary sensors, including the tested binary sensor, to the initial state + 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() + + # Check if changing other binary sensors also triggers + 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_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="binary_sensor.occupancy_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="binary_sensor.occupancy_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + ], +) +async def test_binary_sensor_state_attribute_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the binary sensor state trigger fires when the first binary sensor state changes to a specific state.""" + await async_setup_component(hass, "binary_sensor", {}) + + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + # Set all binary sensors, including the tested binary sensor, to the initial state + 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() + + # Triggering other binary sensors should not cause the trigger to fire again + 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_experimental_triggers_conditions") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "states"), + [ + *parametrize_trigger_states( + trigger="binary_sensor.occupancy_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="binary_sensor.occupancy_cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + additional_attributes={ATTR_DEVICE_CLASS: "occupancy"}, + trigger_from_none=False, + ), + ], +) +async def test_binary_sensor_state_attribute_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_binary_sensors: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + states: list[StateDescription], +) -> None: + """Test that the binary sensor state trigger fires when the last binary sensor state changes to a specific state.""" + await async_setup_component(hass, "binary_sensor", {}) + + other_entity_ids = set(target_binary_sensors["included"]) - {entity_id} + excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id} + + # Set all binary sensors, including the tested binary sensor, to the initial state + 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 diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 561d262c21f..ec6ec640d14 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -43,7 +43,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_climates(hass: HomeAssistant) -> list[str]: """Create multiple climate entities associated with different targets.""" - return await target_entities(hass, "climate") + return (await target_entities(hass, "climate"))["included"] @pytest.mark.parametrize( @@ -113,13 +113,14 @@ async def test_climate_state_trigger_behavior_any( # Set all climates, including the tested climate, to the initial state for eid in target_climates: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -128,7 +129,7 @@ async def test_climate_state_trigger_behavior_any( # Check if changing other climates also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -176,13 +177,14 @@ async def test_climate_state_attribute_trigger_behavior_any( # Set all climates, including the tested climate, to the initial state for eid in target_climates: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -191,7 +193,7 @@ async def test_climate_state_attribute_trigger_behavior_any( # Check if changing other climates also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -243,13 +245,14 @@ async def test_climate_state_trigger_behavior_first( # Set all climates, including the tested climate, to the initial state for eid in target_climates: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -258,7 +261,7 @@ async def test_climate_state_trigger_behavior_first( # Triggering other climates should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -305,13 +308,14 @@ async def test_climate_state_attribute_trigger_behavior_first( # Set all climates, including the tested climate, to the initial state for eid in target_climates: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -320,7 +324,7 @@ async def test_climate_state_attribute_trigger_behavior_first( # Triggering other climates should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -371,18 +375,19 @@ async def test_climate_state_trigger_behavior_last( # Set all climates, including the tested climate, to the initial state for eid in target_climates: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: @@ -432,18 +437,19 @@ async def test_climate_state_attribute_trigger_behavior_last( # Set all climates, including the tested climate, to the initial state for eid in target_climates: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/fan/test_trigger.py b/tests/components/fan/test_trigger.py index 074e34597c3..ae456c1d702 100644 --- a/tests/components/fan/test_trigger.py +++ b/tests/components/fan/test_trigger.py @@ -37,7 +37,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_fans(hass: HomeAssistant) -> list[str]: """Create multiple fan entities associated with different targets.""" - return await target_entities(hass, "fan") + return (await target_entities(hass, "fan"))["included"] @pytest.mark.parametrize( @@ -97,13 +97,14 @@ async def test_fan_state_trigger_behavior_any( # Set all fans, including the tested fan, to the initial state for eid in target_fans: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -112,7 +113,7 @@ async def test_fan_state_trigger_behavior_any( # Check if changing other fans also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -155,13 +156,14 @@ async def test_fan_state_trigger_behavior_first( # Set all fans, including the tested fan, to the initial state for eid in target_fans: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -170,7 +172,7 @@ async def test_fan_state_trigger_behavior_first( # Triggering other fans should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -212,18 +214,19 @@ async def test_fan_state_trigger_behavior_last( # Set all fans, including the tested fan, to the initial state for eid in target_fans: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/lawn_mower/test_trigger.py b/tests/components/lawn_mower/test_trigger.py index 8f8212f41f5..dea18c9898a 100644 --- a/tests/components/lawn_mower/test_trigger.py +++ b/tests/components/lawn_mower/test_trigger.py @@ -39,7 +39,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_lawn_mowers(hass: HomeAssistant) -> list[str]: """Create multiple lawn mower entities associated with different targets.""" - return await target_entities(hass, "lawn_mower") + return (await target_entities(hass, "lawn_mower"))["included"] @pytest.mark.parametrize( @@ -111,13 +111,14 @@ async def test_lawn_mower_state_trigger_behavior_any( # Set all lawn mowers, including the tested one, to the initial state for eid in target_lawn_mowers: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -126,7 +127,7 @@ async def test_lawn_mower_state_trigger_behavior_any( # Check if changing other lawn mowers also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -179,13 +180,14 @@ async def test_lawn_mower_state_trigger_behavior_first( # Set all lawn mowers, including the tested one, to the initial state for eid in target_lawn_mowers: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -194,7 +196,7 @@ async def test_lawn_mower_state_trigger_behavior_first( # Triggering other lawn mowers should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -246,18 +248,19 @@ async def test_lawn_mower_state_trigger_behavior_last( # Set all lawn mowers, including the tested one, to the initial state for eid in target_lawn_mowers: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index 65e5527a7bd..e8dc138f3e2 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -40,13 +40,13 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture async def target_lights(hass: HomeAssistant) -> list[str]: """Create multiple light entities associated with different targets.""" - return await target_entities(hass, "light") + return (await target_entities(hass, "light"))["included"] @pytest.fixture async def target_switches(hass: HomeAssistant) -> list[str]: """Create multiple switch entities associated with different targets.""" - return await target_entities(hass, "switch") + return (await target_entities(hass, "switch"))["included"] async def setup_automation_with_light_condition( diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 6ac9538e2b4..da9ef877710 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -37,7 +37,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_lights(hass: HomeAssistant) -> list[str]: """Create multiple light entities associated with different targets.""" - return await target_entities(hass, "light") + return (await target_entities(hass, "light"))["included"] @pytest.mark.parametrize( @@ -97,13 +97,14 @@ async def test_light_state_trigger_behavior_any( # Set all lights, including the tested light, to the initial state for eid in target_lights: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -112,7 +113,7 @@ async def test_light_state_trigger_behavior_any( # Check if changing other lights also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -155,13 +156,14 @@ async def test_light_state_trigger_behavior_first( # Set all lights, including the tested light, to the initial state for eid in target_lights: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -170,7 +172,7 @@ async def test_light_state_trigger_behavior_first( # Triggering other lights should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -212,18 +214,19 @@ async def test_light_state_trigger_behavior_last( # Set all lights, including the tested light, to the initial state for eid in target_lights: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index 3c06be0abc7..89daa72a03a 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -38,7 +38,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_media_players(hass: HomeAssistant) -> list[str]: """Create multiple media player entities associated with different targets.""" - return await target_entities(hass, "media_player") + return (await target_entities(hass, "media_player"))["included"] @pytest.mark.parametrize( @@ -100,13 +100,14 @@ async def test_media_player_state_trigger_behavior_any( # Set all media players, including the tested media player, to the initial state for eid in target_media_players: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -115,7 +116,7 @@ async def test_media_player_state_trigger_behavior_any( # Check if changing other media players also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -161,13 +162,14 @@ async def test_media_player_state_trigger_behavior_first( # Set all media players, including the tested media player, to the initial state for eid in target_media_players: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -176,7 +178,7 @@ async def test_media_player_state_trigger_behavior_first( # Triggering other media players should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -221,18 +223,19 @@ async def test_media_player_state_trigger_behavior_last( # Set all media players, including the tested media player, to the initial state for eid in target_media_players: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/components/text/test_trigger.py b/tests/components/text/test_trigger.py index f0fa4a2d2a2..34d76088ffc 100644 --- a/tests/components/text/test_trigger.py +++ b/tests/components/text/test_trigger.py @@ -41,7 +41,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_texts(hass: HomeAssistant) -> list[str]: """Create multiple text entities associated with different targets.""" - return await target_entities(hass, "text") + return (await target_entities(hass, "text"))["included"] @pytest.mark.parametrize( @@ -94,43 +94,50 @@ async def test_text_triggers_gated_by_labs_flag( ( "text.changed", [ - {"state": None, "attributes": {}, "count": 0}, - {"state": "bar", "attributes": {}, "count": 0}, - {"state": "baz", "attributes": {}, "count": 1}, + {"included": {"state": None, "attributes": {}}, "count": 0}, + {"included": {"state": "bar", "attributes": {}}, "count": 0}, + {"included": {"state": "baz", "attributes": {}}, "count": 1}, ], ), ( "text.changed", [ - {"state": "foo", "attributes": {}, "count": 0}, - {"state": "bar", "attributes": {}, "count": 1}, - {"state": "baz", "attributes": {}, "count": 1}, + {"included": {"state": "foo", "attributes": {}}, "count": 0}, + {"included": {"state": "bar", "attributes": {}}, "count": 1}, + {"included": {"state": "baz", "attributes": {}}, "count": 1}, ], ), ( "text.changed", [ - {"state": "foo", "attributes": {}, "count": 0}, - {"state": "", "attributes": {}, "count": 1}, # empty string - {"state": "baz", "attributes": {}, "count": 1}, + {"included": {"state": "foo", "attributes": {}}, "count": 0}, + # empty string + {"included": {"state": "", "attributes": {}}, "count": 1}, + {"included": {"state": "baz", "attributes": {}}, "count": 1}, ], ), ( "text.changed", [ - {"state": STATE_UNAVAILABLE, "attributes": {}, "count": 0}, - {"state": "bar", "attributes": {}, "count": 0}, - {"state": "baz", "attributes": {}, "count": 1}, - {"state": STATE_UNAVAILABLE, "attributes": {}, "count": 0}, + { + "included": {"state": STATE_UNAVAILABLE, "attributes": {}}, + "count": 0, + }, + {"included": {"state": "bar", "attributes": {}}, "count": 0}, + {"included": {"state": "baz", "attributes": {}}, "count": 1}, + { + "included": {"state": STATE_UNAVAILABLE, "attributes": {}}, + "count": 0, + }, ], ), ( "text.changed", [ - {"state": STATE_UNKNOWN, "attributes": {}, "count": 0}, - {"state": "bar", "attributes": {}, "count": 0}, - {"state": "baz", "attributes": {}, "count": 1}, - {"state": STATE_UNKNOWN, "attributes": {}, "count": 0}, + {"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0}, + {"included": {"state": "bar", "attributes": {}}, "count": 0}, + {"included": {"state": "baz", "attributes": {}}, "count": 1}, + {"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0}, ], ), ], @@ -152,13 +159,14 @@ async def test_text_state_trigger_behavior_any( # Set all texts, including the tested text, to the initial state for eid in target_texts: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, None, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -167,7 +175,7 @@ async def test_text_state_trigger_behavior_any( # Check if changing other texts also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() diff --git a/tests/components/vacuum/test_trigger.py b/tests/components/vacuum/test_trigger.py index 70d50e90158..a17a505590f 100644 --- a/tests/components/vacuum/test_trigger.py +++ b/tests/components/vacuum/test_trigger.py @@ -39,7 +39,7 @@ def enable_experimental_triggers_conditions() -> Generator[None]: @pytest.fixture async def target_vacuums(hass: HomeAssistant) -> list[str]: """Create multiple vacuum entities associated with different targets.""" - return await target_entities(hass, "vacuum") + return (await target_entities(hass, "vacuum"))["included"] @pytest.mark.parametrize( @@ -111,13 +111,14 @@ async def test_vacuum_state_trigger_behavior_any( # Set all vacuums, including the tested one, to the initial state for eid in target_vacuums: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -126,7 +127,7 @@ async def test_vacuum_state_trigger_behavior_any( # Check if changing other vacuums also triggers for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == (entities_in_target - 1) * state["count"] service_calls.clear() @@ -179,13 +180,14 @@ async def test_vacuum_state_trigger_behavior_first( # Set all vacuums, including the tested one, to the initial state for eid in target_vacuums: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config) for state in states[1:]: - set_or_remove_state(hass, entity_id, state) + 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: @@ -194,7 +196,7 @@ async def test_vacuum_state_trigger_behavior_first( # Triggering other vacuums should not cause the trigger to fire again for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 @@ -246,18 +248,19 @@ async def test_vacuum_state_trigger_behavior_last( # Set all vacuums, including the tested one, to the initial state for eid in target_vacuums: - set_or_remove_state(hass, eid, states[0]) + set_or_remove_state(hass, eid, states[0]["included"]) await hass.async_block_till_done() await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config) for state in states[1:]: + included_state = state["included"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, state) + set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() assert len(service_calls) == 0 - set_or_remove_state(hass, entity_id, state) + 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: diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index ad5c852ded9..222de84b21c 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -188,10 +188,10 @@ async def test_caching(hass: HomeAssistant) -> None: side_effect=icon.build_resources, ) as mock_build: load1 = await icon.async_get_icons(hass, "entity_component") - assert len(mock_build.mock_calls) == 2 + assert len(mock_build.mock_calls) == 3 # entity_component, services, triggers load2 = await icon.async_get_icons(hass, "entity_component") - assert len(mock_build.mock_calls) == 2 + assert len(mock_build.mock_calls) == 3 # entity_component, services, triggers assert load1 == load2 diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 608da382e5e..34f0212ee34 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -672,6 +672,12 @@ async def test_platform_backwards_compatibility_for_new_style_configs( """, ], ) +# Patch out binary sensor triggers, because loading sun triggers also loads +# binary sensor triggers and those are irrelevant for this test +@patch( + "homeassistant.components.binary_sensor.trigger.async_get_triggers", + new=AsyncMock(return_value={}), +) async def test_async_get_all_descriptions( hass: HomeAssistant, hass_ws_client: WebSocketGenerator,