From 59711ba797f9df28bf7b8a3e99cee8b50f8c83d8 Mon Sep 17 00:00:00 2001 From: abmantis Date: Mon, 27 Apr 2026 18:36:36 +0100 Subject: [PATCH] Extract triggers/conditions/services for non-primary entities --- .../components/websocket_api/automation.py | 30 ++++++-- .../components/websocket_api/test_commands.py | 71 ++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/websocket_api/automation.py b/homeassistant/components/websocket_api/automation.py index 5efd6de792a5..b2ddd83f5fae 100644 --- a/homeassistant/components/websocket_api/automation.py +++ b/homeassistant/components/websocket_api/automation.py @@ -10,7 +10,7 @@ from typing import Any, Self from homeassistant.const import CONF_TARGET from homeassistant.core import HomeAssistant -from homeassistant.helpers import target as target_helpers +from homeassistant.helpers import entity_registry as er, target as target_helpers from homeassistant.helpers.condition import ( async_get_all_descriptions as async_get_all_condition_descriptions, ) @@ -92,12 +92,14 @@ class _AutomationComponentLookupData: component: str filters: list[_EntityFilter] + primary_entities_only: bool = True @classmethod def create(cls, component: str, target_description: dict[str, Any]) -> Self: """Build automation component lookup data from target description.""" filters: list[_EntityFilter] = [] + primary_entities_only = target_description.get("primary_entities_only", True) entity_filters_config = target_description.get("entity", []) for entity_filter_config in entity_filters_config: entity_filter = _EntityFilter( @@ -110,14 +112,28 @@ class _AutomationComponentLookupData: ) filters.append(entity_filter) - return cls(component=component, filters=filters) + return cls( + component=component, + filters=filters, + primary_entities_only=primary_entities_only, + ) def matches( - self, hass: HomeAssistant, entity_id: str, domain: str, integration: str + self, + hass: HomeAssistant, + entity_id: str, + domain: str, + integration: str, + check_entity_category: bool, ) -> bool: """Return if entity matches ANY of the filters.""" if not self.filters: return True + + if check_entity_category and self.primary_entities_only: + entry = er.async_get(hass).async_get(entity_id) + if entry is None or entry.entity_category is not None: + return False return any( f.matches(hass, entity_id, domain, integration) for f in self.filters ) @@ -220,6 +236,7 @@ def _async_get_automation_components_for_target( hass, target_helpers.TargetSelection(target_selection), expand_group=expand_group, + primary_entities_only=False, ) _LOGGER.debug("Extracted entities for lookup: %s", extracted) @@ -230,6 +247,7 @@ def _async_get_automation_components_for_target( "Automation components per domain: %s", lookup_table.domain_components ) + check_entity_category = len(extracted.indirectly_referenced) > 0 entity_infos = entity_sources(hass) matched_components: set[str] = set() for entity_id in extracted.referenced | extracted.indirectly_referenced: @@ -253,7 +271,11 @@ def _async_get_automation_components_for_target( if component_data.component in matched_components: continue if component_data.matches( - hass, entity_id, entity_domain, entity_integration + hass, + entity_id, + entity_domain, + entity_integration, + check_entity_category, ): matched_components.add(component_data.component) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 10cc6fc7d6bf..f23dca634c08 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -177,6 +177,24 @@ async def target_entities( switch_platform.config_entry = config_entry await switch_platform.async_add_entities([device1_switch, area_device_switch]) + area_device_diagnostic_sensor = MockEntity( + entity_id="sensor.test7", + unique_id="test7", + device_info=dr.DeviceInfo(identifiers=area_device.identifiers), + entity_category=EntityCategory.DIAGNOSTIC, + ) + label2_device_config_sensor = MockEntity( + entity_id="sensor.test8", + unique_id="test8", + device_info=dr.DeviceInfo(identifiers=label2_device.identifiers), + entity_category=EntityCategory.CONFIG, + ) + sensor_platform = MockEntityPlatform(hass, domain="sensor", platform_name="test") + sensor_platform.config_entry = config_entry + await sensor_platform.async_add_entities( + [area_device_diagnostic_sensor, label2_device_config_sensor] + ) + component1_light = MockEntity( entity_id="light.component1_light", unique_id="component1_light" ) @@ -246,6 +264,8 @@ async def target_entities( "light.test6", "switch.test2", "switch.test5", + "sensor.test7", + "sensor.test8", "light.component1_light", "light.component1_flash_light", "light.component1_effect_flash_light", @@ -3795,7 +3815,11 @@ async def test_get_triggers_conditions_for_target( Mock( **{ f"async_get_{automation_component}s": AsyncMock( - return_value={"match_all": Mock, "other_integration_lights": Mock} + return_value={ + "match_all": Mock, + "other_integration_lights": Mock, + "non_primary_sensor": Mock, + } ) } ), @@ -3873,6 +3897,12 @@ async def test_get_triggers_conditions_for_target( - light.LightEntityFeature.EFFECT - integration: test domain: light + + non_primary_sensor: + target: + entity: + domain: sensor + primary_entities_only: false """ def _load_yaml(fname, secrets=None): @@ -3978,6 +4008,7 @@ async def test_get_triggers_conditions_for_target( "component1", "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turned_on", "sensor.turned_on", @@ -3990,6 +4021,7 @@ async def test_get_triggers_conditions_for_target( {"area_id": ["kitchen", "living_room"]}, [ "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turned_on", "switch.turned_on", @@ -4003,10 +4035,23 @@ async def test_get_triggers_conditions_for_target( "light.turned_on", "component1", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "switch.turned_on", ], ) + + # Test direct targeting of a non-primary entity - even + # primary_entities_only=True components match + await assert_command( + {"entity_id": ["sensor.test7"]}, + [ + "component2.match_all", + "component2.non_primary_sensor", + "sensor.turned_on", + ], + ) + # Test mixed target types await assert_command( { @@ -4019,6 +4064,7 @@ async def test_get_triggers_conditions_for_target( "component1", "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turned_on", "sensor.turned_on", @@ -4107,6 +4153,12 @@ async def test_get_services_for_target( - light.LightEntityFeature.EFFECT - integration: test domain: light + + non_primary_sensor: + target: + entity: + domain: sensor + primary_entities_only: false """ def _load_yaml(fname, secrets=None): @@ -4145,6 +4197,7 @@ async def test_get_services_for_target( hass.services.async_register( "component2", "other_integration_lights", lambda call: None ) + hass.services.async_register("component2", "non_primary_sensor", lambda call: None) await hass.async_block_till_done() async def assert_services( @@ -4226,6 +4279,7 @@ async def test_get_services_for_target( [ "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turn_on", "sensor.turn_on", @@ -4238,6 +4292,7 @@ async def test_get_services_for_target( {"area_id": ["kitchen", "living_room"]}, [ "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turn_on", "switch.turn_on", @@ -4250,10 +4305,23 @@ async def test_get_services_for_target( [ "light.turn_on", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "switch.turn_on", ], ) + + # Test direct targeting of a non-primary entity - even + # primary_entities_only=True components match + await assert_services( + {"entity_id": ["sensor.test7"]}, + [ + "component2.match_all", + "component2.non_primary_sensor", + "sensor.turn_on", + ], + ) + # Test mixed target types await assert_services( { @@ -4265,6 +4333,7 @@ async def test_get_services_for_target( [ "component1.light_message", "component2.match_all", + "component2.non_primary_sensor", "component2.other_integration_lights", "light.turn_on", "sensor.turn_on",