From b856e04825096721059581c30fd4b71ae32d822b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 16 Jan 2026 07:39:59 +0100 Subject: [PATCH] Add assist_satellite conditions (#161019) --- .../components/assist_satellite/condition.py | 23 +++ .../assist_satellite/conditions.yaml | 19 ++ .../components/assist_satellite/icons.json | 14 ++ .../components/assist_satellite/strings.json | 50 +++++ .../components/automation/__init__.py | 1 + .../assist_satellite/test_condition.py | 190 ++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 homeassistant/components/assist_satellite/condition.py create mode 100644 homeassistant/components/assist_satellite/conditions.yaml create mode 100644 tests/components/assist_satellite/test_condition.py diff --git a/homeassistant/components/assist_satellite/condition.py b/homeassistant/components/assist_satellite/condition.py new file mode 100644 index 00000000000..0c0a402d6f5 --- /dev/null +++ b/homeassistant/components/assist_satellite/condition.py @@ -0,0 +1,23 @@ +"""Provides conditions for assist satellites.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from .const import DOMAIN +from .entity import AssistSatelliteState + +CONDITIONS: dict[str, type[Condition]] = { + "is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE), + "is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING), + "is_processing": make_entity_state_condition( + DOMAIN, AssistSatelliteState.PROCESSING + ), + "is_responding": make_entity_state_condition( + DOMAIN, AssistSatelliteState.RESPONDING + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the assist satellite conditions.""" + return CONDITIONS diff --git a/homeassistant/components/assist_satellite/conditions.yaml b/homeassistant/components/assist_satellite/conditions.yaml new file mode 100644 index 00000000000..eeb7f02b913 --- /dev/null +++ b/homeassistant/components/assist_satellite/conditions.yaml @@ -0,0 +1,19 @@ +.condition_common: &condition_common + target: + entity: + domain: assist_satellite + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_idle: *condition_common +is_listening: *condition_common +is_processing: *condition_common +is_responding: *condition_common diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index 975b943416d..c4f15d320de 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -1,4 +1,18 @@ { + "conditions": { + "is_idle": { + "condition": "mdi:chat-sleep" + }, + "is_listening": { + "condition": "mdi:chat-question" + }, + "is_processing": { + "condition": "mdi:chat-processing" + }, + "is_responding": { + "condition": "mdi:chat-alert" + } + }, "entity_component": { "_": { "default": "mdi:account-voice" diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 95ce20a851c..4680df87f33 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -1,8 +1,52 @@ { "common": { + "condition_behavior_description": "How the state should match on the targeted Assist satellites.", + "condition_behavior_name": "Behavior", "trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_idle": { + "description": "Tests if one or more Assist satellites are idle.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is idle" + }, + "is_listening": { + "description": "Tests if one or more Assist satellites are listening.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is listening" + }, + "is_processing": { + "description": "Tests if one or more Assist satellites are processing.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is processing" + }, + "is_responding": { + "description": "Tests if one or more Assist satellites are responding.", + "fields": { + "behavior": { + "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", + "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + } + }, + "name": "If a satellite is responding" + } + }, "entity_component": { "_": { "name": "Assist satellite", @@ -21,6 +65,12 @@ "sentences": "Sentences" } }, + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 34097bb989b..4835d9149d4 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -124,6 +124,7 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions" _EXPERIMENTAL_CONDITION_PLATFORMS = { "alarm_control_panel", + "assist_satellite", "fan", "light", } diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py new file mode 100644 index 00000000000..afd1c958329 --- /dev/null +++ b/tests/components/assist_satellite/test_condition.py @@ -0,0 +1,190 @@ +"""Test assist satellite conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.assist_satellite.entity import AssistSatelliteState +from homeassistant.core import HomeAssistant + +from tests.components import ( + ConditionStateDescription, + assert_condition_gated_by_labs_flag, + create_target_condition, + other_states, + parametrize_condition_states, + parametrize_target_entities, + 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 +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"))["included"] + + +@pytest.mark.parametrize( + "condition", + [ + "assist_satellite.is_idle", + "assist_satellite.is_listening", + "assist_satellite.is_processing", + "assist_satellite.is_responding", + ], +) +async def test_assist_satellite_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the assist satellite conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("assist_satellite"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states( + condition="assist_satellite.is_idle", + target_states=[AssistSatelliteState.IDLE], + other_states=other_states(AssistSatelliteState.IDLE), + ), + *parametrize_condition_states( + condition="assist_satellite.is_listening", + target_states=[AssistSatelliteState.LISTENING], + other_states=other_states(AssistSatelliteState.LISTENING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_processing", + target_states=[AssistSatelliteState.PROCESSING], + other_states=other_states(AssistSatelliteState.PROCESSING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_responding", + target_states=[AssistSatelliteState.RESPONDING], + other_states=other_states(AssistSatelliteState.RESPONDING), + ), + ], +) +async def test_assist_satellite_state_condition_behavior_any( + hass: HomeAssistant, + target_assist_satellites: 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 the assist satellite state condition with the 'any' behavior.""" + other_entity_ids = set(target_assist_satellites) - {entity_id} + + # 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]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="any", + ) + + for state in states: + included_state = state["included"] + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + # Check if changing other assist satellites also passes the condition + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + assert condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("assist_satellite"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states( + condition="assist_satellite.is_idle", + target_states=[AssistSatelliteState.IDLE], + other_states=other_states(AssistSatelliteState.IDLE), + ), + *parametrize_condition_states( + condition="assist_satellite.is_listening", + target_states=[AssistSatelliteState.LISTENING], + other_states=other_states(AssistSatelliteState.LISTENING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_processing", + target_states=[AssistSatelliteState.PROCESSING], + other_states=other_states(AssistSatelliteState.PROCESSING), + ), + *parametrize_condition_states( + condition="assist_satellite.is_responding", + target_states=[AssistSatelliteState.RESPONDING], + other_states=other_states(AssistSatelliteState.RESPONDING), + ), + ], +) +async def test_assist_satellite_state_condition_behavior_all( + hass: HomeAssistant, + target_assist_satellites: 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 the assist satellite state condition with the 'all' behavior.""" + other_entity_ids = set(target_assist_satellites) - {entity_id} + + # 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]["included"]) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition=condition, + target=condition_target_config, + behavior="all", + ) + + for state in states: + included_state = state["included"] + + set_or_remove_state(hass, entity_id, included_state) + await hass.async_block_till_done() + # The condition passes if all entities are either in a target state or invalid + assert condition(hass) == ( + (not state["state_valid"]) + or (state["condition_true"] and entities_in_target == 1) + ) + + for other_entity_id in other_entity_ids: + set_or_remove_state(hass, other_entity_id, included_state) + await hass.async_block_till_done() + + # The condition passes if all entities are either in a target state or invalid + assert condition(hass) == ( + (not state["state_valid"]) or state["condition_true"] + )