Compare commits

...

8 Commits

6 changed files with 139 additions and 13 deletions
+19 -5
View File
@@ -30,19 +30,33 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
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,
),
}
@@ -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:
@@ -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)
+13 -1
View File
@@ -16,6 +16,7 @@ import sys
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Final,
Literal,
Never,
@@ -448,6 +449,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
@@ -615,7 +619,10 @@ class EntityConditionBase(Condition):
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
@@ -663,6 +670,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).
@@ -686,6 +694,7 @@ def make_entity_state_condition(
else ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
)
_states = states_set
_primary_entities_only = primary_entities_only
return CustomCondition
@@ -793,6 +802,8 @@ class EntityNumericalConditionBase(EntityConditionBase):
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)
@@ -802,6 +813,7 @@ def make_entity_numerical_condition(
_domain_specs = specs
_valid_unit = valid_unit
_primary_entities_only = primary_entities_only
return CustomCondition
+7 -2
View File
@@ -9,6 +9,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
STATE_OFF,
STATE_ON,
EntityCategory,
)
from homeassistant.core import HomeAssistant
@@ -32,13 +33,17 @@ _BATTERY_UNIT_ATTRS = {ATTR_UNIT_OF_MEASUREMENT: "%"}
@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(
@@ -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.potato",
unique_id="potato",
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.potato",
"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",