diff --git a/homeassistant/components/battery/condition.py b/homeassistant/components/battery/condition.py index 7004d9f00fbf93..c89e9bd7321608 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 b03f9516bd9446..39ffc09109f22d 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 d8d62709f207c7..615c0f9d1da921 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, @@ -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 @@ -554,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) @@ -615,7 +620,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 @@ -663,6 +671,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 +695,7 @@ class CustomCondition(EntityStateConditionBase): else ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL ) _states = states_set + _primary_entities_only = primary_entities_only return CustomCondition @@ -793,6 +803,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) @@ -802,6 +814,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 dd8606171c8348..e0cb7d4be85e3c 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 @@ -32,13 +33,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/helpers/test_condition.py b/tests/helpers/test_condition.py index bcf1e2c3fd7a32..c146fef26ece97 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. @@ -3169,13 +3201,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( @@ -3188,12 +3223,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, } @@ -3282,7 +3314,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) @@ -3300,7 +3332,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) @@ -3315,7 +3347,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 @@ -3344,7 +3376,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="%", ) @@ -3383,7 +3415,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, ) @@ -3411,7 +3443,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 @@ -4017,17 +4049,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( @@ -4040,12 +4074,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 {}, } @@ -4058,7 +4089,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) @@ -4071,7 +4102,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") @@ -4101,7 +4132,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.async_check() is False @@ -4110,7 +4141,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}, ) @@ -4128,7 +4161,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}, ) @@ -4146,7 +4181,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 @@ -4157,7 +4192,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")}, ) @@ -4183,7 +4218,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}, ) @@ -4208,7 +4243,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, @@ -4231,7 +4266,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, @@ -4256,7 +4291,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, @@ -4275,7 +4310,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, @@ -4294,7 +4329,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, @@ -4332,7 +4367,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, @@ -4375,7 +4410,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, @@ -4392,7 +4429,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, @@ -5116,6 +5155,126 @@ async def test_state_condition_attr_duration_unrelated_attr_update( assert test.async_check() 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 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() + + 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 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 matching (6s total since primary became matching): + # - primary_entities_only=True: diagnostic is excluded from evaluation, + # only primary is checked. Primary has been matching for 6s >= 5s → True. + # - primary_entities_only=False: diagnostic is included. Diagnostic has + # 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 matching). Now diagnostic + # has also been matching 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: