mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 18:55:09 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0190dc16a6 | |||
| 9b29b07329 | |||
| 59711ba797 | |||
| 999d987108 | |||
| f660ddddea | |||
| 47579a9ac7 | |||
| c65c502e2f | |||
| 13e28210aa |
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user