From 13e28210aa1562c9f771a5ef47d287c61f07ef43 Mon Sep 17 00:00:00 2001 From: abmantis Date: Wed, 22 Apr 2026 22:44:26 +0100 Subject: [PATCH 1/7] Allow extracting non-primary entities in websocket command --- .../components/websocket_api/commands.py | 6 +- homeassistant/helpers/target.py | 78 +++++++++++----- .../components/websocket_api/test_commands.py | 88 ++++++++++++++++++- tests/helpers/test_target.py | 31 +++++++ 4 files changed, 177 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e083a8253b14a..b99e865112186 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -865,6 +865,7 @@ def handle_entity_source( vol.Required("type"): "extract_from_target", vol.Required("target"): cv.TARGET_FIELDS, vol.Optional("expand_group", default=False): bool, + vol.Optional("primary_entities_only", default=True): bool, } ) def handle_extract_from_target( @@ -874,7 +875,10 @@ def handle_extract_from_target( target_selection = target_helpers.TargetSelection(msg["target"]) extracted = target_helpers.async_extract_referenced_entity_ids( - hass, target_selection, expand_group=msg["expand_group"] + hass, + target_selection, + expand_group=msg["expand_group"], + primary_entities_only=msg["primary_entities_only"], ) extracted_dict = { diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 334b7147e01b0..efca86d3b5ed1 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -147,9 +147,21 @@ def log_missing(self, missing_entities: set[str], logger: Logger) -> None: def async_extract_referenced_entity_ids( - hass: HomeAssistant, target_selection: TargetSelection, expand_group: bool = True + hass: HomeAssistant, + target_selection: TargetSelection, + expand_group: bool = True, + *, + primary_entities_only: bool = True, ) -> SelectedEntities: - """Extract referenced entity IDs from a target selection.""" + """Extract referenced entity IDs from a target selection. + + When `primary_entities_only` is True (the default), entities with an + `entity_category` (i.e. config or diagnostic entities) are excluded from + indirect expansion via device, area, and floor. When False, those entities + are included. Direct label-to-entity expansion is unaffected by this flag, + but label targeting via labeled devices or areas is still filtered because + those paths expand through device/area selection. + """ selected = SelectedEntities() if not target_selection.has_any_target: @@ -217,14 +229,18 @@ def async_extract_referenced_entity_ids( if not selected.referenced_areas and not selected.referenced_devices: return selected + def _include_entry(entry: er.RegistryEntry) -> bool: + """Return True if the entry should be included in indirect expansion.""" + if entry.hidden_by is not None: + return False + return not primary_entities_only or entry.entity_category is None + # Add indirectly referenced by device selected.indirectly_referenced.update( entry.entity_id for device_id in selected.referenced_devices for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if (entry.entity_category is None and entry.hidden_by is None) + if _include_entry(entry) ) # Find devices for targeted areas @@ -243,27 +259,16 @@ def async_extract_referenced_entity_ids( for area_id in selected.referenced_areas # The entity's area matches a targeted area for entry in entities.get_entries_for_area_id(area_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if entry.entity_category is None and entry.hidden_by is None + if _include_entry(entry) ) # Add indirectly referenced by area through device selected.indirectly_referenced.update( entry.entity_id for device_id in referenced_devices_by_area for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if ( - entry.entity_category is None - and entry.hidden_by is None - and ( - # The entity's device matches a device referenced - # by an area and the entity - # has no explicitly set area - not entry.area_id - ) - ) + # The entity's device matches a device referenced by an area and the + # entity has no explicitly set area. + if _include_entry(entry) and not entry.area_id ) return selected @@ -277,11 +282,14 @@ def __init__( hass: HomeAssistant, target_selection: TargetSelection, entity_filter: Callable[[set[str]], set[str]], + *, + primary_entities_only: bool = True, ) -> None: """Initialize the state change tracker.""" self._hass = hass self._target_selection = target_selection self._entity_filter = entity_filter + self._primary_entities_only = primary_entities_only self._registry_unsubs: list[CALLBACK_TYPE] = [] @@ -300,7 +308,10 @@ def _handle_entities_update(self, tracked_entities: set[str]) -> None: def _handle_target_update(self, event: Event[Any] | None = None) -> None: """Handle updates in the tracked targets.""" selected = async_extract_referenced_entity_ids( - self._hass, self._target_selection, expand_group=False + self._hass, + self._target_selection, + expand_group=False, + primary_entities_only=self._primary_entities_only, ) filtered_entities = self._entity_filter( selected.referenced | selected.indirectly_referenced @@ -345,9 +356,16 @@ def __init__( target_selection: TargetSelection, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]], + *, + primary_entities_only: bool = True, ) -> None: """Initialize the state change tracker.""" - super().__init__(hass, target_selection, entity_filter) + super().__init__( + hass, + target_selection, + entity_filter, + primary_entities_only=primary_entities_only, + ) self._action = action self._state_change_unsub: CALLBACK_TYPE | None = None @@ -380,12 +398,24 @@ def async_track_target_selector_state_change_event( target_selector_config: ConfigType, action: Callable[[TargetStateChangedData], Any], entity_filter: Callable[[set[str]], set[str]] = lambda x: x, + *, + primary_entities_only: bool = True, ) -> CALLBACK_TYPE: - """Track state changes for entities referenced directly or indirectly in a target selector.""" + """Track state changes for entities referenced directly or indirectly in a target selector. + + When `primary_entities_only` is True, indirect target expansion (via device, area, + and floor) skips entities with an `entity_category` (i.e. config or diagnostic entities). + """ target_selection = TargetSelection(target_selector_config) if not target_selection.has_any_target: raise HomeAssistantError( f"Target selector {target_selector_config} does not have any selectors defined" ) - tracker = TargetStateChangeTracker(hass, target_selection, action, entity_filter) + tracker = TargetStateChangeTracker( + hass, + target_selection, + action, + entity_filter, + primary_entities_only=primary_entities_only, + ) return tracker.async_setup() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a0b2943cc1eb2..10cc6fc7d6bf8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -34,7 +34,11 @@ ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_EXTERNAL_URL, SIGNAL_BOOTSTRAP_INTEGRATIONS +from homeassistant.const import ( + CONF_EXTERNAL_URL, + SIGNAL_BOOTSTRAP_INTEGRATIONS, + EntityCategory, +) from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( @@ -3604,6 +3608,88 @@ async def test_extract_from_target_expand_group( ) +async def test_extract_from_target_primary_entities_only( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test extract_from_target command with primary_entities_only parameter.""" + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device1")}, + ) + + primary_entity = entity_registry.async_get_or_create( + "light", "test", "unique1", device_id=device.id + ) + diagnostic_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + device_id=device.id, + entity_category=EntityCategory.DIAGNOSTIC, + ) + config_entity = entity_registry.async_get_or_create( + "switch", + "test", + "unique3", + device_id=device.id, + entity_category=EntityCategory.CONFIG, + ) + + # Default (primary_entities_only=True): config/diagnostic entities excluded + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"device_id": [device.id]}, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={primary_entity.entity_id}, + devices={device.id}, + ) + + # Explicit primary_entities_only=True + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"device_id": [device.id]}, + "primary_entities_only": True, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={primary_entity.entity_id}, + devices={device.id}, + ) + + # primary_entities_only=False: config/diagnostic entities included + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"device_id": [device.id]}, + "primary_entities_only": False, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={ + primary_entity.entity_id, + diagnostic_entity.entity_id, + config_entity.entity_id, + }, + devices={device.id}, + ) + + async def test_extract_from_target_missing_entities( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 92a8a0e2ee2a3..114e3445bf057 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -499,6 +499,37 @@ async def test_extract_referenced_entity_ids( ) +@pytest.mark.parametrize( + ("selector_config", "non_primary_entities"), + [ + ({ATTR_AREA_ID: "own-area"}, {"light.config_in_own_area"}), + ({ATTR_DEVICE_ID: "device-no-area-id"}, {"light.config_no_area"}), + ({ATTR_AREA_ID: "test-area"}, {"light.config_in_area"}), + ], +) +@pytest.mark.usefixtures("registries_mock") +async def test_extract_referenced_entity_ids_primary_entities_only( + hass: HomeAssistant, + selector_config: ConfigType, + non_primary_entities: set[str], +) -> None: + """Test that primary_entities_only controls inclusion of config/diagnostic entities.""" + target_selection = target.TargetSelection(selector_config) + + selected_primary = target.async_extract_referenced_entity_ids( + hass, target_selection, expand_group=False, primary_entities_only=True + ) + selected_all = target.async_extract_referenced_entity_ids( + hass, target_selection, expand_group=False, primary_entities_only=False + ) + + assert ( + selected_all.indirectly_referenced + == selected_primary.indirectly_referenced | non_primary_entities + ) + assert non_primary_entities.isdisjoint(selected_primary.indirectly_referenced) + + async def test_async_track_target_selector_state_change_event_empty_selector( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From c65c502e2f08f70304e6c8ecd4d76351a0e42e25 Mon Sep 17 00:00:00 2001 From: abmantis Date: Wed, 22 Apr 2026 22:44:26 +0100 Subject: [PATCH 2/7] Allow targeting non-primary entities in triggers --- homeassistant/components/battery/trigger.py | 20 +++++++++++++------ .../components/battery/triggers.yaml | 3 +++ homeassistant/helpers/selector.py | 2 ++ homeassistant/helpers/trigger.py | 19 +++++++++++++++++- tests/components/battery/test_trigger.py | 9 +++++++-- tests/components/common.py | 19 +++++++++++++++++- 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py index 426dae8256976..3f9bcb43092ec 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -32,19 +32,27 @@ } TRIGGERS: dict[str, type[Trigger]] = { - "low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON), - "not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF), + "low": make_entity_target_state_trigger( + BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False + ), + "not_low": make_entity_target_state_trigger( + BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False + ), "started_charging": make_entity_target_state_trigger( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON + BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False ), "stopped_charging": make_entity_target_state_trigger( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF + BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False ), "level_changed": make_entity_numerical_state_changed_trigger( - BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + BATTERY_PERCENTAGE_DOMAIN_SPECS, + valid_unit="%", + primary_entities_only=False, ), "level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + BATTERY_PERCENTAGE_DOMAIN_SPECS, + valid_unit="%", + primary_entities_only=False, ), } diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml index 055ba84068ae9..ee8eea8cad329 100644 --- a/homeassistant/components/battery/triggers.yaml +++ b/homeassistant/components/battery/triggers.yaml @@ -33,16 +33,19 @@ entity: - domain: binary_sensor device_class: battery + primary_entities_only: false .trigger_target_charging: &trigger_target_charging entity: - domain: binary_sensor device_class: battery_charging + primary_entities_only: false .trigger_target_percentage: &trigger_target_percentage entity: - domain: sensor device_class: battery + primary_entities_only: false low: fields: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index cc53b3acb90da..b885081f83ba6 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1878,6 +1878,7 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] + primary_entities_only: bool @SELECTORS.register("target") @@ -1899,6 +1900,7 @@ class TargetSelector(Selector[TargetSelectorConfig]): cv.ensure_list, [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], ), + vol.Optional("primary_entities_only"): cv.boolean, } ) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 9d68c8e0a17ac..a9d717e47f063 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -14,6 +14,7 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, Final, Literal, Protocol, @@ -357,6 +358,9 @@ class EntityTriggerBase(Trigger): {STATE_UNAVAILABLE, STATE_UNKNOWN} ) _schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST + # When True, indirect target expansion (via device/area/floor) skips + # entities with an entity_category. + _primary_entities_only: ClassVar[bool] = True @override @classmethod @@ -518,7 +522,11 @@ def call_action() -> None: ) unsub = async_track_target_selector_state_change_event( - self._hass, self._target, state_change_listener, self.entity_filter + self._hass, + self._target, + state_change_listener, + self.entity_filter, + primary_entities_only=self._primary_entities_only, ) @callback @@ -891,6 +899,8 @@ def _normalize_domain_specs( def make_entity_target_state_trigger( domain_specs: Mapping[str, DomainSpec] | str, to_states: str | set[str], + *, + primary_entities_only: bool = True, ) -> type[EntityTargetStateTriggerBase]: """Create a trigger for entity state changes to specific state(s). @@ -909,6 +919,7 @@ class CustomTrigger(EntityTargetStateTriggerBase): _domain_specs = specs _to_states = to_states_set + _primary_entities_only = primary_entities_only return CustomTrigger @@ -960,6 +971,8 @@ class CustomTrigger(EntityOriginStateTriggerBase): def make_entity_numerical_state_changed_trigger( domain_specs: Mapping[str, DomainSpec], valid_unit: str | None | UndefinedType = UNDEFINED, + *, + primary_entities_only: bool = True, ) -> type[EntityNumericalStateChangedTriggerBase]: """Create a trigger for numerical state value change.""" @@ -968,6 +981,7 @@ class CustomTrigger(EntityNumericalStateChangedTriggerBase): _domain_specs = domain_specs _valid_unit = valid_unit + _primary_entities_only = primary_entities_only return CustomTrigger @@ -975,6 +989,8 @@ class CustomTrigger(EntityNumericalStateChangedTriggerBase): def make_entity_numerical_state_crossed_threshold_trigger( domain_specs: Mapping[str, DomainSpec], valid_unit: str | None | UndefinedType = UNDEFINED, + *, + primary_entities_only: bool = True, ) -> type[EntityNumericalStateCrossedThresholdTriggerBase]: """Create a trigger for numerical state value crossing a threshold.""" @@ -983,6 +999,7 @@ class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerBase): _domain_specs = domain_specs _valid_unit = valid_unit + _primary_entities_only = primary_entities_only return CustomTrigger diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py index 0240b6b4ccc6b..b737515577df0 100644 --- a/tests/components/battery/test_trigger.py +++ b/tests/components/battery/test_trigger.py @@ -10,6 +10,7 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, STATE_ON, + EntityCategory, ) from homeassistant.core import HomeAssistant @@ -31,13 +32,17 @@ @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") + return await target_entities( + hass, "binary_sensor", entity_category=EntityCategory.DIAGNOSTIC + ) @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple sensor entities associated with different targets.""" - return await target_entities(hass, "sensor") + return await target_entities( + hass, "sensor", entity_category=EntityCategory.DIAGNOSTIC + ) @pytest.mark.parametrize( diff --git a/tests/components/common.py b/tests/components/common.py index e8e539f26af19..9741f299b8010 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -23,6 +23,7 @@ CONF_TARGET, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityCategory, ) from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import ( @@ -48,13 +49,22 @@ async def target_entities( - hass: HomeAssistant, domain: str, *, domain_excluded: str | None = None + hass: HomeAssistant, + domain: str, + *, + domain_excluded: str | None = None, + entity_category: EntityCategory | None = None, ) -> dict[str, list[str]]: """Create multiple entities associated with different targets. If `domain_excluded` is provided, entities in excluded_entities will have this domain, otherwise they will have the same domain as included_entities. + If `entity_category` is provided, all created registry entities (i.e. the + area-, device-, and label-associated entities) are created with that + entity category. Standalone entities are referenced directly by entity_id + and are unaffected. + Returns a dict with the following keys: - included_entities: List of entity_ids meant to be targeted. - excluded_entities: List of entity_ids not meant to be targeted. @@ -89,6 +99,7 @@ async def target_entities( platform="test", unique_id=f"{domain}_area", suggested_object_id=f"area_{domain}", + entity_category=entity_category, ) entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id) entity_area_excluded = entity_reg.async_get_or_create( @@ -96,6 +107,7 @@ async def target_entities( platform="test", unique_id=f"{domain_excluded}_area_excluded", suggested_object_id=f"area_{domain_excluded}_excluded", + entity_category=entity_category, ) entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id) @@ -106,6 +118,7 @@ async def target_entities( unique_id=f"{domain}_device", suggested_object_id=f"device_{domain}", device_id=device.id, + entity_category=entity_category, ) entity_reg.async_get_or_create( domain=domain, @@ -113,6 +126,7 @@ async def target_entities( unique_id=f"{domain}_device2", suggested_object_id=f"device2_{domain}", device_id=device.id, + entity_category=entity_category, ) entity_reg.async_get_or_create( domain=domain_excluded, @@ -120,6 +134,7 @@ async def target_entities( unique_id=f"{domain_excluded}_device_excluded", suggested_object_id=f"device_{domain_excluded}_excluded", device_id=device.id, + entity_category=entity_category, ) # Entities associated with label @@ -128,6 +143,7 @@ async def target_entities( platform="test", unique_id=f"{domain}_label", suggested_object_id=f"label_{domain}", + entity_category=entity_category, ) entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id}) entity_label_excluded = entity_reg.async_get_or_create( @@ -135,6 +151,7 @@ async def target_entities( platform="test", unique_id=f"{domain_excluded}_label_excluded", suggested_object_id=f"label_{domain_excluded}_excluded", + entity_category=entity_category, ) entity_reg.async_update_entity( entity_label_excluded.entity_id, labels={label.label_id} From f660ddddea8f538856c53fa5c4b7533135c658c0 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 27 Apr 2026 12:53:31 +0100 Subject: [PATCH 3/7] Add target selector tests for primary_entities_only field --- tests/helpers/test_selector.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 5995f021f59d2..7641bd90f0830 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1185,6 +1185,24 @@ def test_serial_port_selector_schema( (), (), ), + ( + {"primary_entities_only": True}, + (), + (), + ), + ( + {"primary_entities_only": False}, + (), + (), + ), + ( + { + "entity": {"domain": "light"}, + "primary_entities_only": True, + }, + (), + (), + ), ], ) def test_target_selector_schema(schema, valid_selections, invalid_selections) -> None: From 999d9871084b065d7f64551cb2255cdab0dd1d29 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 27 Apr 2026 14:42:16 +0100 Subject: [PATCH 4/7] Allow targeting non-primary entities in conditions --- homeassistant/components/battery/condition.py | 24 +++++++++++++++---- .../components/battery/conditions.yaml | 4 ++++ homeassistant/helpers/condition.py | 14 ++++++++++- tests/components/battery/test_condition.py | 9 +++++-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/battery/condition.py b/homeassistant/components/battery/condition.py index 7004d9f00fbf9..c89e9bd732160 100644 --- a/homeassistant/components/battery/condition.py +++ b/homeassistant/components/battery/condition.py @@ -30,19 +30,33 @@ CONDITIONS: dict[str, type[Condition]] = { "is_low": make_entity_state_condition( - BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True + BATTERY_DOMAIN_SPECS, + STATE_ON, + support_duration=True, + primary_entities_only=False, ), "is_not_low": make_entity_state_condition( - BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True + BATTERY_DOMAIN_SPECS, + STATE_OFF, + support_duration=True, + primary_entities_only=False, ), "is_charging": make_entity_state_condition( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True + BATTERY_CHARGING_DOMAIN_SPECS, + STATE_ON, + support_duration=True, + primary_entities_only=False, ), "is_not_charging": make_entity_state_condition( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True + BATTERY_CHARGING_DOMAIN_SPECS, + STATE_OFF, + support_duration=True, + primary_entities_only=False, ), "is_level": make_entity_numerical_condition( - BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE + BATTERY_PERCENTAGE_DOMAIN_SPECS, + PERCENTAGE, + primary_entities_only=False, ), } diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index b03f9516bd944..39ffc09109f22 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -3,6 +3,7 @@ entity: - domain: binary_sensor device_class: battery + primary_entities_only: false fields: behavior: &condition_behavior required: true @@ -42,6 +43,7 @@ is_charging: entity: - domain: binary_sensor device_class: battery_charging + primary_entities_only: false fields: behavior: *condition_behavior for: *condition_for @@ -51,6 +53,7 @@ is_not_charging: entity: - domain: binary_sensor device_class: battery_charging + primary_entities_only: false fields: behavior: *condition_behavior for: *condition_for @@ -60,6 +63,7 @@ is_level: entity: - domain: sensor device_class: battery + primary_entities_only: false fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 6d53f674188b7..41ca3536c79c7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -16,6 +16,7 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, Final, Literal, Never, @@ -443,6 +444,9 @@ class EntityConditionBase(Condition): _domain_specs: Mapping[str, DomainSpec] _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL + # When True, indirect target expansion (via device/area/floor) skips + # entities with an entity_category. + _primary_entities_only: ClassVar[bool] = True @override @classmethod @@ -506,7 +510,10 @@ def _check_all_match_state(self, states: list[State]) -> bool: def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test state condition.""" targeted_entities = async_extract_referenced_entity_ids( - self._hass, self._target_selection, expand_group=False + self._hass, + self._target_selection, + expand_group=False, + primary_entities_only=self._primary_entities_only, ) referenced_entity_ids = targeted_entities.referenced.union( targeted_entities.indirectly_referenced @@ -545,6 +552,7 @@ def make_entity_state_condition( states: str | bool | set[str | bool], *, support_duration: bool = False, + primary_entities_only: bool = True, ) -> type[EntityStateConditionBase]: """Create a condition for entity state changes to specific state(s). @@ -568,6 +576,7 @@ class CustomCondition(EntityStateConditionBase): else ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL ) _states = states_set + _primary_entities_only = primary_entities_only return CustomCondition @@ -675,6 +684,8 @@ def is_valid_state(self, entity_state: State) -> bool: def make_entity_numerical_condition( domain_specs: Mapping[str, DomainSpec] | str, valid_unit: str | None | UndefinedType = UNDEFINED, + *, + primary_entities_only: bool = True, ) -> type[EntityNumericalConditionBase]: """Create a condition for numerical state comparisons.""" specs = _normalize_domain_specs(domain_specs) @@ -684,6 +695,7 @@ class CustomCondition(EntityNumericalConditionBase): _domain_specs = specs _valid_unit = valid_unit + _primary_entities_only = primary_entities_only return CustomCondition diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index 8c828c0add8c9..5e011431f2092 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -9,6 +9,7 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, STATE_ON, + EntityCategory, ) from homeassistant.core import HomeAssistant @@ -31,13 +32,17 @@ @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") + return await target_entities( + hass, "binary_sensor", entity_category=EntityCategory.DIAGNOSTIC + ) @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple sensor entities associated with different targets.""" - return await target_entities(hass, "sensor") + return await target_entities( + hass, "sensor", entity_category=EntityCategory.DIAGNOSTIC + ) @pytest.mark.parametrize( From 1962013f828d2c279dc121c8ce03bcc27dfdd8aa Mon Sep 17 00:00:00 2001 From: abmantis Date: Tue, 28 Apr 2026 15:10:50 +0100 Subject: [PATCH 5/7] Add flag to state change tracker --- homeassistant/helpers/condition.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 9df4ea9519137..615c0f9d1da92 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -558,6 +558,7 @@ def _on_entities_update(added: set[str], removed: set[str]) -> None: _state_change_listener, self.entity_filter, _on_entities_update, + primary_entities_only=self._primary_entities_only, ) self._on_unload.append(unsub) From fdbb3f2a2ad136f358e80846c141738add2b31a6 Mon Sep 17 00:00:00 2001 From: abmantis Date: Tue, 28 Apr 2026 15:48:54 +0100 Subject: [PATCH 6/7] Add tests --- tests/helpers/test_condition.py | 225 +++++++++++++++++++++++++++----- 1 file changed, 192 insertions(+), 33 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 593b22328f282..d6eb8f16716de 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -23,6 +23,7 @@ from homeassistant.components.sun import DOMAIN as SUN_DOMAIN from homeassistant.components.system_health import DOMAIN as SYSTEM_HEALTH_DOMAIN from homeassistant.const import ( + ATTR_AREA_ID, ATTR_DEVICE_CLASS, ATTR_LABEL_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -37,11 +38,13 @@ STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import ( + area_registry as ar, condition, config_validation as cv, entity_registry as er, @@ -78,6 +81,35 @@ from tests.typing import WebSocketGenerator +async def _create_primary_and_diagnostic_entities_in_area( + hass: HomeAssistant, domain: str +) -> tuple[str, str, str]: + """Create a primary and a diagnostic entity in the same area. + + Returns a tuple of (area_id, primary_entity_id, diagnostic_entity_id). + """ + area_reg = ar.async_get(hass) + area = area_reg.async_create("Test Area") + + entity_reg = er.async_get(hass) + primary = entity_reg.async_get_or_create( + domain=domain, + platform="test", + unique_id=f"{domain}_primary", + suggested_object_id=f"primary_{domain}", + ) + entity_reg.async_update_entity(primary.entity_id, area_id=area.id) + diagnostic = entity_reg.async_get_or_create( + domain=domain, + platform="test", + unique_id=f"{domain}_diagnostic", + suggested_object_id=f"diagnostic_{domain}", + entity_category=EntityCategory.DIAGNOSTIC, + ) + entity_reg.async_update_entity(diagnostic.entity_id, area_id=area.id) + return area.id, primary.entity_id, diagnostic.entity_id + + def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -3140,13 +3172,16 @@ async def good_subscriber(new_conditions: set[str]): async def _setup_numerical_condition( hass: HomeAssistant, condition_options: dict[str, Any], - entity_ids: str | list[str], + target_config: dict[str, Any], domain_specs: Mapping[str, DomainSpec] | None = None, valid_unit: str | None | UndefinedType = UNDEFINED, + primary_entities_only: bool = True, ) -> condition.ConditionChecker: """Set up a numerical condition via a mock platform and return the test.""" condition_cls = make_entity_numerical_condition( - domain_specs or _DEFAULT_DOMAIN_SPECS, valid_unit + domain_specs or _DEFAULT_DOMAIN_SPECS, + valid_unit, + primary_entities_only=primary_entities_only, ) async def async_get_conditions( @@ -3159,12 +3194,9 @@ async def async_get_conditions( hass, "test.condition", Mock(async_get_conditions=async_get_conditions) ) - if isinstance(entity_ids, str): - entity_ids = [entity_ids] - config: dict[str, Any] = { CONF_CONDITION: "test", - CONF_TARGET: {CONF_ENTITY_ID: entity_ids}, + CONF_TARGET: target_config, CONF_OPTIONS: condition_options, } @@ -3253,7 +3285,7 @@ async def test_numerical_condition_thresholds( test = await _setup_numerical_condition( hass, condition_options=condition_options, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, ) hass.states.async_set("test.entity_1", state_value) @@ -3271,7 +3303,7 @@ async def test_numerical_condition_invalid_state( test = await _setup_numerical_condition( hass, condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, ) hass.states.async_set("test.entity_1", state_value) @@ -3286,7 +3318,7 @@ async def test_numerical_condition_attribute_value_source( hass, domain_specs={"test": DomainSpec(value_source="brightness")}, condition_options={"threshold": {"type": "above", "value": {"number": 100}}}, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, ) # Attribute above threshold -> True @@ -3315,7 +3347,7 @@ async def test_numerical_condition_attribute_value_source_skips_unit_check( hass, domain_specs={"test": DomainSpec(value_source="humidity")}, condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, valid_unit="%", ) @@ -3354,7 +3386,7 @@ async def test_numerical_condition_valid_unit( test = await _setup_numerical_condition( hass, condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, valid_unit=valid_unit, ) @@ -3382,7 +3414,7 @@ async def test_numerical_condition_behavior( "threshold": {"type": "above", "value": {"number": 50}}, ATTR_BEHAVIOR: behavior, }, - entity_ids=["test.entity_1", "test.entity_2"], + target_config={CONF_ENTITY_ID: ["test.entity_1", "test.entity_2"]}, ) # Both above -> True for any and all @@ -3988,17 +4020,19 @@ async def test_numerical_condition_with_unit_behavior( async def _setup_state_condition( hass: HomeAssistant, - entity_ids: str | list[str], states: str | bool | set[str | bool], + target_config: dict[str, Any], condition_options: dict[str, Any] | None = None, domain_specs: Mapping[str, DomainSpec] | None = None, support_duration: bool = False, + primary_entities_only: bool = True, ) -> condition.ConditionChecker: """Set up a state condition via a mock platform and return the checker.""" condition_cls = make_entity_state_condition( domain_specs or _DEFAULT_DOMAIN_SPECS, states, support_duration=support_duration, + primary_entities_only=primary_entities_only, ) async def async_get_conditions( @@ -4011,12 +4045,9 @@ async def async_get_conditions( hass, "test.condition", Mock(async_get_conditions=async_get_conditions) ) - if isinstance(entity_ids, str): - entity_ids = [entity_ids] - config: dict[str, Any] = { CONF_CONDITION: "test", - CONF_TARGET: {CONF_ENTITY_ID: entity_ids}, + CONF_TARGET: target_config, CONF_OPTIONS: condition_options or {}, } @@ -4029,7 +4060,7 @@ async def async_get_conditions( async def test_state_condition_single_entity(hass: HomeAssistant) -> None: """Test state condition with a single entity.""" test = await _setup_state_condition( - hass, entity_ids="test.entity_1", states=STATE_ON + hass, target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON ) hass.states.async_set("test.entity_1", STATE_ON) @@ -4042,7 +4073,7 @@ async def test_state_condition_single_entity(hass: HomeAssistant) -> None: async def test_state_condition_multiple_target_states(hass: HomeAssistant) -> None: """Test state condition matching any of multiple target states.""" test = await _setup_state_condition( - hass, entity_ids="test.entity_1", states={"on", "heat"} + hass, target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states={"on", "heat"} ) hass.states.async_set("test.entity_1", "on") @@ -4072,7 +4103,7 @@ async def test_state_condition_unavailable_unknown( """ # Single entity: unavailable/unknown → False test_single = await _setup_state_condition( - hass, entity_ids="test.entity_1", states=STATE_ON + hass, target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON ) hass.states.async_set("test.entity_1", state_value) assert test_single(hass) is False @@ -4081,7 +4112,9 @@ async def test_state_condition_unavailable_unknown( # → True (entity_1 matches, entity_2 is skipped) test_any = await _setup_state_condition( hass, - entity_ids=["test.entity_1", "test.entity_2", "test.entity_3"], + target_config={ + CONF_ENTITY_ID: ["test.entity_1", "test.entity_2", "test.entity_3"] + }, states=STATE_ON, condition_options={ATTR_BEHAVIOR: BEHAVIOR_ANY}, ) @@ -4099,7 +4132,9 @@ async def test_state_condition_unavailable_unknown( # → True (all *available* entities match, entity_2 is skipped) test_all = await _setup_state_condition( hass, - entity_ids=["test.entity_1", "test.entity_2", "test.entity_3"], + target_config={ + CONF_ENTITY_ID: ["test.entity_1", "test.entity_2", "test.entity_3"] + }, states=STATE_ON, condition_options={ATTR_BEHAVIOR: BEHAVIOR_ALL}, ) @@ -4117,7 +4152,7 @@ async def test_state_condition_unavailable_unknown( async def test_state_condition_entity_not_found(hass: HomeAssistant) -> None: """Test state condition when entity does not exist.""" test = await _setup_state_condition( - hass, entity_ids="test.nonexistent", states=STATE_ON + hass, target_config={CONF_ENTITY_ID: ["test.nonexistent"]}, states=STATE_ON ) # Entity doesn't exist — condition should be false @@ -4128,7 +4163,7 @@ async def test_state_condition_attribute_value_source(hass: HomeAssistant) -> No """Test state condition reads from attribute when value_source is set.""" test = await _setup_state_condition( hass, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states="heat", domain_specs={"test": DomainSpec(value_source="hvac_action")}, ) @@ -4154,7 +4189,7 @@ async def test_state_condition_behavior( """Test state condition with behavior any/all.""" test = await _setup_state_condition( hass, - entity_ids=["test.entity_1", "test.entity_2"], + target_config={CONF_ENTITY_ID: ["test.entity_1", "test.entity_2"]}, states=STATE_ON, condition_options={ATTR_BEHAVIOR: behavior}, ) @@ -4179,7 +4214,7 @@ async def test_state_condition_duration_not_met( """Test state condition with duration: entity hasn't been in state long enough.""" test = await _setup_state_condition( hass, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON, condition_options={CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -4202,7 +4237,7 @@ async def test_state_condition_duration_met( """Test state condition with duration: entity has been in state long enough.""" test = await _setup_state_condition( hass, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON, condition_options={CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -4227,7 +4262,7 @@ async def test_state_condition_duration_zero_behaves_like_no_duration( """ test = await _setup_state_condition( hass, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON, condition_options={CONF_FOR: {"seconds": 0}}, support_duration=True, @@ -4246,7 +4281,7 @@ async def test_state_condition_duration_wrong_state( """Test state condition with duration: entity in wrong state even after duration.""" test = await _setup_state_condition( hass, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON, condition_options={CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -4265,7 +4300,7 @@ async def test_state_condition_duration_reset_on_state_change( """Test state condition with duration: timer resets when state changes.""" test = await _setup_state_condition( hass, - entity_ids="test.entity_1", + target_config={CONF_ENTITY_ID: ["test.entity_1"]}, states=STATE_ON, condition_options={CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -4303,7 +4338,7 @@ async def test_state_condition_duration_behavior( """Test state condition with duration and behavior any/all.""" test = await _setup_state_condition( hass, - entity_ids=["test.entity_1", "test.entity_2"], + target_config={CONF_ENTITY_ID: ["test.entity_1", "test.entity_2"]}, states=STATE_ON, condition_options={ATTR_BEHAVIOR: behavior, CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -4346,7 +4381,9 @@ async def test_state_condition_duration_unavailable_unknown( # → True (entity_1 matches and meets duration, entity_2 skipped) test_any = await _setup_state_condition( hass, - entity_ids=["test.entity_1", "test.entity_2", "test.entity_3"], + target_config={ + CONF_ENTITY_ID: ["test.entity_1", "test.entity_2", "test.entity_3"] + }, states=STATE_ON, condition_options={ATTR_BEHAVIOR: BEHAVIOR_ANY, CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -4363,7 +4400,9 @@ async def test_state_condition_duration_unavailable_unknown( # → True (all available entities match and meet duration) test_all = await _setup_state_condition( hass, - entity_ids=["test.entity_1", "test.entity_2", "test.entity_3"], + target_config={ + CONF_ENTITY_ID: ["test.entity_1", "test.entity_2", "test.entity_3"] + }, states=STATE_ON, condition_options={ATTR_BEHAVIOR: BEHAVIOR_ALL, CONF_FOR: {"seconds": 10}}, support_duration=True, @@ -5070,6 +5109,126 @@ async def test_state_condition_attr_duration_unrelated_attr_update( assert test(hass) is True +@pytest.mark.parametrize(("primary_entities_only"), [True, False]) +async def test_state_condition_primary_entities_only( + hass: HomeAssistant, primary_entities_only: bool +) -> None: + """Test make_entity_state_condition primary_entities_only flag.""" + ( + area_id, + primary_id, + diagnostic_id, + ) = await _create_primary_and_diagnostic_entities_in_area(hass, "test") + + test = await _setup_state_condition( + hass, + target_config={ATTR_AREA_ID: area_id}, + states=STATE_ON, + condition_options={ATTR_BEHAVIOR: BEHAVIOR_ALL}, + primary_entities_only=primary_entities_only, + ) + + # Primary on, diagnostic off + hass.states.async_set(primary_id, STATE_ON) + hass.states.async_set(diagnostic_id, STATE_OFF) + await hass.async_block_till_done() + # If diagnostic is included (primary_entities_only=False), behavior=all fails because + # the diagnostic entity is off. If excluded, only the primary is checked and it's on. + assert test(hass) is primary_entities_only + + # Both on - true regardless of flag + hass.states.async_set(diagnostic_id, STATE_ON) + await hass.async_block_till_done() + assert test(hass) is True + + +@pytest.mark.parametrize(("primary_entities_only"), [True, False]) +async def test_numerical_condition_primary_entities_only( + hass: HomeAssistant, + primary_entities_only: bool, +) -> None: + """Test make_entity_numerical_condition primary_entities_only flag.""" + ( + area_id, + primary_id, + diagnostic_id, + ) = await _create_primary_and_diagnostic_entities_in_area(hass, "test") + + test = await _setup_numerical_condition( + hass, + target_config={ATTR_AREA_ID: area_id}, + condition_options={ + "threshold": {"type": "above", "value": {"number": 50}}, + ATTR_BEHAVIOR: BEHAVIOR_ALL, + }, + primary_entities_only=primary_entities_only, + ) + + # Primary above threshold, diagnostic below + hass.states.async_set(primary_id, "75") + hass.states.async_set(diagnostic_id, "25") + await hass.async_block_till_done() + # If diagnostic is included (primary_entities_only=False), behavior=all fails because + # the diagnostic value is below the threshold. If excluded, only the primary is + # checked and it's above. + assert test(hass) is primary_entities_only + + # Both above threshold — true regardless of flag + hass.states.async_set(diagnostic_id, "75") + await hass.async_block_till_done() + assert test(hass) is True + + +@pytest.mark.parametrize(("primary_entities_only"), [True, False]) +async def test_state_condition_primary_entities_only_with_duration( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + primary_entities_only: bool, +) -> None: + """Test make_entity_state_condition primary_entities_only flag with duration.""" + ( + area_id, + primary_id, + diagnostic_id, + ) = await _create_primary_and_diagnostic_entities_in_area(hass, "test") + + # Primary starts with valid attribute, diagnostic with invalid attribute + hass.states.async_set(primary_id, STATE_ON, {"test_attr": True}) + hass.states.async_set(diagnostic_id, STATE_ON, {"test_attr": False}) + await hass.async_block_till_done() + + test = await _setup_state_condition( + hass, + target_config={ATTR_AREA_ID: area_id}, + states={True}, + domain_specs={"test": DomainSpec(value_source="test_attr")}, + condition_options={ + ATTR_BEHAVIOR: BEHAVIOR_ALL, + CONF_FOR: {"seconds": 5}, + }, + support_duration=True, + primary_entities_only=primary_entities_only, + ) + + # 3s later, diagnostic transitions to valid. The state-change listener + freezer.tick(timedelta(seconds=3)) + hass.states.async_set(diagnostic_id, STATE_ON, {"test_attr": True}) + await hass.async_block_till_done() + + # 3s after diagnostic became valid (6s total since primary became valid): + # - primary_entities_only=True: diagnostic is excluded from evaluation, + # only primary is checked. Primary has been valid for 6s >= 5s → True. + # - primary_entities_only=False: diagnostic is included. Diagnostic has + # only been valid for 3s < 5s → behavior=all is False. + freezer.tick(timedelta(seconds=3)) + assert test(hass) is primary_entities_only + + # 3 more seconds later (6s after diagnostic became valid). Now diagnostic + # has also been valid for >= 5s → True regardless of flag. + freezer.tick(timedelta(seconds=3)) + assert test(hass) is True + + async def test_async_from_config_calls_async_setup_on_checker( hass: HomeAssistant, ) -> None: From aa34e63924ff3006fc8a30b26aa07191199a24a8 Mon Sep 17 00:00:00 2001 From: abmantis Date: Wed, 29 Apr 2026 10:56:19 +0100 Subject: [PATCH 7/7] Valid -> matching --- tests/helpers/test_condition.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 19074712fab2a..c146fef26ece9 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -5238,7 +5238,7 @@ async def test_state_condition_primary_entities_only_with_duration( diagnostic_id, ) = await _create_primary_and_diagnostic_entities_in_area(hass, "test") - # Primary starts with valid attribute, diagnostic with invalid attribute + # Primary starts with matching attribute, diagnostic with non-matching attribute hass.states.async_set(primary_id, STATE_ON, {"test_attr": True}) hass.states.async_set(diagnostic_id, STATE_ON, {"test_attr": False}) await hass.async_block_till_done() @@ -5256,21 +5256,21 @@ async def test_state_condition_primary_entities_only_with_duration( primary_entities_only=primary_entities_only, ) - # 3s later, diagnostic transitions to valid. The state-change listener + # 3s later, diagnostic transitions to matching. The state-change listener freezer.tick(timedelta(seconds=3)) hass.states.async_set(diagnostic_id, STATE_ON, {"test_attr": True}) await hass.async_block_till_done() - # 3s after diagnostic became valid (6s total since primary became valid): + # 3s after diagnostic became matching (6s total since primary became matching): # - primary_entities_only=True: diagnostic is excluded from evaluation, - # only primary is checked. Primary has been valid for 6s >= 5s → True. + # only primary is checked. Primary has been matching for 6s >= 5s → True. # - primary_entities_only=False: diagnostic is included. Diagnostic has - # only been valid for 3s < 5s → behavior=all is False. + # only been matching for 3s < 5s → behavior=all is False. freezer.tick(timedelta(seconds=3)) assert test(hass) is primary_entities_only - # 3 more seconds later (6s after diagnostic became valid). Now diagnostic - # has also been valid for >= 5s → True regardless of flag. + # 3 more seconds later (6s after diagnostic became matching). Now diagnostic + # has also been matching for >= 5s → True regardless of flag. freezer.tick(timedelta(seconds=3)) assert test(hass) is True