Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions homeassistant/components/websocket_api/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

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,
)
Expand Down Expand Up @@ -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(
Expand All @@ -110,14 +112,29 @@ def create(cls, component: str, target_description: dict[str, Any]) -> Self:
)
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 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:
Comment thread
abmantis marked this conversation as resolved.
Outdated
return False

if not self.filters:
return True

return any(
f.matches(hass, entity_id, domain, integration) for f in self.filters
)
Expand Down Expand Up @@ -220,6 +237,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)

Expand All @@ -230,6 +248,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:
Expand All @@ -253,7 +272,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)

Expand Down
130 changes: 127 additions & 3 deletions tests/components/websocket_api/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ async def target_entities(
kitchen_area = area_registry.async_create("Kitchen")
living_room_area = area_registry.async_create("Living Room")
label_area = area_registry.async_create("Bathroom")
garage_area = area_registry.async_create("Garage")
label1 = label_registry.async_create("Label 1")
label2 = label_registry.async_create("Label 2")
label3 = label_registry.async_create("Label 3")

area_registry.async_update(label_area.id, labels={label1.label_id})

Expand All @@ -132,13 +134,20 @@ async def target_entities(
label2_device = dr.DeviceEntry(
id="label_device", identifiers={("test", "device4")}, labels={label2.label_id}
)
diag_only_device = dr.DeviceEntry(
id="diag_only_device",
identifiers={("test", "device5")},
area_id=garage_area.id,
labels={label3.label_id},
)
mock_device_registry(
hass,
{
device1.id: device1,
device2.id: device2,
area_device.id: area_device,
label2_device.id: label2_device,
diag_only_device.id: diag_only_device,
},
)

Expand Down Expand Up @@ -177,6 +186,34 @@ 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,
Comment thread
abmantis marked this conversation as resolved.
)
diag_only_device_sensor = MockEntity(
entity_id="sensor.test9",
unique_id="test9",
device_info=dr.DeviceInfo(identifiers=diag_only_device.identifiers),
entity_category=EntityCategory.DIAGNOSTIC,
)
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,
diag_only_device_sensor,
]
)

component1_light = MockEntity(
entity_id="light.component1_light", unique_id="component1_light"
)
Expand Down Expand Up @@ -246,20 +283,24 @@ async def target_entities(
"light.test6",
"switch.test2",
"switch.test5",
"sensor.test7",
"sensor.test8",
"sensor.test9",
"light.component1_light",
"light.component1_flash_light",
"light.component1_effect_flash_light",
"light.component1_flash_transition_light",
"switch.component1_switch",
"sensor.component1_sensor",
}
assert set(label_registry.labels) == {"label_1", "label_2"}
assert set(area_registry.areas) == {"kitchen", "living_room", "bathroom"}
assert set(label_registry.labels) == {"label_1", "label_2", "label_3"}
assert set(area_registry.areas) == {"kitchen", "living_room", "bathroom", "garage"}
assert set(dr.async_get(hass).devices) == {
"device1",
"device2",
"area_device",
"label_device",
"diag_only_device",
}


Expand Down Expand Up @@ -3795,7 +3836,11 @@ async def async_get_triggers_conditions(hass: HomeAssistant) -> dict[str, type]:
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,
}
)
}
),
Expand Down Expand Up @@ -3873,6 +3918,12 @@ def get_common_descriptions(domain: str):
- light.LightEntityFeature.EFFECT
- integration: test
domain: light

non_primary_sensor:
target:
entity:
domain: sensor
primary_entities_only: false
"""

def _load_yaml(fname, secrets=None):
Expand Down Expand Up @@ -3978,6 +4029,7 @@ async def assert_command(
"component1",
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turned_on",
"sensor.turned_on",
Expand All @@ -3990,6 +4042,7 @@ async def assert_command(
{"area_id": ["kitchen", "living_room"]},
[
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turned_on",
"switch.turned_on",
Expand All @@ -4003,10 +4056,40 @@ async def assert_command(
"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 indirect targeting (device/area/label) with a target that only
# contains non-primary entities. Components with no entity filter and
# the default primary_entities_only=True (e.g. component2.match_all)
# must NOT match.
await assert_command(
{"device_id": ["diag_only_device"]},
["component2.non_primary_sensor"],
)
await assert_command(
{"area_id": ["garage"]},
["component2.non_primary_sensor"],
)
await assert_command(
{"label_id": ["label_3"]},
["component2.non_primary_sensor"],
)

Comment thread
abmantis marked this conversation as resolved.
# Test mixed target types
await assert_command(
{
Expand All @@ -4019,6 +4102,7 @@ async def assert_command(
"component1",
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turned_on",
"sensor.turned_on",
Expand Down Expand Up @@ -4107,6 +4191,12 @@ def get_common_service_descriptions(domain: str):
- light.LightEntityFeature.EFFECT
- integration: test
domain: light

non_primary_sensor:
target:
entity:
domain: sensor
primary_entities_only: false
"""

def _load_yaml(fname, secrets=None):
Expand Down Expand Up @@ -4145,6 +4235,7 @@ def _load_yaml(fname, secrets=None):
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(
Expand Down Expand Up @@ -4226,6 +4317,7 @@ async def assert_services(
[
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turn_on",
"sensor.turn_on",
Expand All @@ -4238,6 +4330,7 @@ async def assert_services(
{"area_id": ["kitchen", "living_room"]},
[
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turn_on",
"switch.turn_on",
Expand All @@ -4250,10 +4343,40 @@ async def assert_services(
[
"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 indirect targeting (device/area/label) with a target that only
# contains non-primary entities. Services with no entity filter and
# the default primary_entities_only=True (e.g. component2.match_all)
# must NOT match.
await assert_services(
{"device_id": ["diag_only_device"]},
["component2.non_primary_sensor"],
)
await assert_services(
{"area_id": ["garage"]},
["component2.non_primary_sensor"],
)
await assert_services(
{"label_id": ["label_3"]},
["component2.non_primary_sensor"],
)

# Test mixed target types
await assert_services(
{
Expand All @@ -4265,6 +4388,7 @@ async def assert_services(
[
"component1.light_message",
"component2.match_all",
"component2.non_primary_sensor",
"component2.other_integration_lights",
"light.turn_on",
"sensor.turn_on",
Expand Down
Loading