Compare commits

...

1 Commits

Author SHA1 Message Date
Erik 522bfaff07 Add method _should_include to EntityConditionBase 2026-05-06 08:00:13 +02:00
4 changed files with 91 additions and 7 deletions
+16 -5
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -65,6 +65,20 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -88,10 +102,7 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity": ClimateTargetHumidityCondition,
"target_temperature": ClimateTargetTemperatureCondition,
}
+15 -2
View File
@@ -437,6 +437,9 @@ class EntityConditionBase(Condition):
"""Base class for entity conditions."""
_domain_specs: Mapping[str, DomainSpec]
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
@@ -501,7 +504,7 @@ class EntityConditionBase(Condition):
"""
if (
_state is not None
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
and self.is_valid_state(_state)
):
# Only record the time if not already tracked, to avoid
@@ -566,6 +569,16 @@ class EntityConditionBase(Condition):
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def _should_include(self, _state: State) -> bool:
"""Check if an entity should participate in any/all checks.
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the condition relies on.
"""
return _state.state not in self._excluded_states
@abc.abstractmethod
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected state(s)."""
@@ -622,7 +635,7 @@ class EntityConditionBase(Condition):
_state
for entity_id in filtered_entity_ids
if (_state := self._hass.states.get(entity_id))
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
]
return self._matcher(entity_states)
@@ -332,6 +332,7 @@ async def test_climate_attribute_condition_behavior_all(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_any(
"climate.target_temperature",
@@ -376,6 +377,7 @@ async def test_climate_numerical_condition_behavior_any(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_all(
"climate.target_temperature",
+58
View File
@@ -246,6 +246,7 @@ def _parametrize_condition_states(
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None,
required_filter_attributes: dict | None,
condition_true_if_invalid: bool,
excluded_entities_from_other_domain: bool,
@@ -261,6 +262,7 @@ def _parametrize_condition_states(
required_filter_attributes = required_filter_attributes or {}
condition_options = condition_options or {}
extra_excluded_states = extra_excluded_states or []
add_excluded_state = excluded_entities_from_other_domain or bool(
required_filter_attributes
)
@@ -314,6 +316,18 @@ def _parametrize_condition_states(
STATE_UNKNOWN, condition_true_if_invalid, True
),
),
# `extra_excluded_states` are filtered by the condition's
# `_should_include` override exactly like
# missing/unavailable/unknown, so they share the
# `condition_true_if_invalid` expectation: vacuous True
# under behavior=all (every entity filtered → all-check
# vacuous), vacuous False under behavior=any.
(
state_with_attributes(
extra_excluded_state, condition_true_if_invalid, True
)
for extra_excluded_state in extra_excluded_states
),
(
state_with_attributes(other_state, False, False)
for other_state in other_states
@@ -342,6 +356,7 @@ def parametrize_condition_states_any(
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None,
required_filter_attributes: dict | None = None,
excluded_entities_from_other_domain: bool = False,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
@@ -364,6 +379,13 @@ def parametrize_condition_states_any(
other_states: States the condition is expected to evaluate False for.
Same accepted shapes as `target_states`. With behavior=any, an
entity in such a state does not satisfy the condition.
extra_excluded_states: *Additional* states (on top of the always-
included missing/unavailable/unknown) that the condition's
`_should_include` override is expected to filter out. Under
behavior=any, every targeted entity sitting in a filtered state
yields `any([]) → False`, so these share the built-in invalid
states' expectation. Set this for conditions whose
`_should_include` skips entities lacking the tracked attribute.
required_filter_attributes: Attributes that must be present on the
entity for the condition's domain filter to accept it. The
helper merges these into every generated state so the entity
@@ -380,6 +402,7 @@ def parametrize_condition_states_any(
condition_options=condition_options,
target_states=target_states,
other_states=other_states,
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
condition_true_if_invalid=False,
excluded_entities_from_other_domain=excluded_entities_from_other_domain,
@@ -392,6 +415,7 @@ def parametrize_condition_states_all(
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None,
required_filter_attributes: dict | None = None,
excluded_entities_from_other_domain: bool = False,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
@@ -416,6 +440,13 @@ def parametrize_condition_states_all(
for. Same accepted shapes as `target_states`. Under behavior=all,
an entity in such a state blocks the all-check (counts toward
the check but is not a match).
extra_excluded_states: *Additional* states (on top of the always-
included missing/unavailable/unknown) that the condition's
`_should_include` override is expected to filter out. Under
behavior=all, every targeted entity sitting in a filtered state
yields `all([]) → True` (vacuous), so these share the built-in
invalid states' expectation. Set this for conditions whose
`_should_include` skips entities lacking the tracked attribute.
required_filter_attributes: Attributes that must be present on the
entity for the condition's domain filter to accept it. The
helper merges these into every generated state so the entity
@@ -432,6 +463,7 @@ def parametrize_condition_states_all(
condition_options=condition_options,
target_states=target_states,
other_states=other_states,
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
condition_true_if_invalid=True,
excluded_entities_from_other_domain=excluded_entities_from_other_domain,
@@ -2095,6 +2127,7 @@ def parametrize_numerical_attribute_condition_above_below_any(
required_filter_attributes: dict | None = None,
threshold_unit: str | None | UndefinedType = UNDEFINED,
unit_attributes: dict | None = None,
attribute_required: bool = False,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=any.
@@ -2134,9 +2167,18 @@ def parametrize_numerical_attribute_condition_above_below_any(
`{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated
state, so the entity carries a unit alongside its tracked
attribute.
attribute_required: When True, `(state, {attribute: None})` is
classified as an *excluded* state (filtered out of the all/any
check by the condition's `_should_include` override) rather
than treated as just-missing. Set this for conditions whose
`_should_include` skips entities lacking the tracked
attribute.
"""
condition_options = condition_options or {}
unit_attributes = unit_attributes or {}
extra_excluded_states = (
[(state, {attribute: None} | unit_attributes)] if attribute_required else None
)
return [
*parametrize_condition_states_any(
@@ -2158,6 +2200,7 @@ def parametrize_numerical_attribute_condition_above_below_any(
(state, {attribute: 10} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_any(
@@ -2179,6 +2222,7 @@ def parametrize_numerical_attribute_condition_above_below_any(
(state, {attribute: 90} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_any(
@@ -2205,6 +2249,7 @@ def parametrize_numerical_attribute_condition_above_below_any(
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
]
@@ -2219,6 +2264,7 @@ def parametrize_numerical_attribute_condition_above_below_all(
required_filter_attributes: dict | None = None,
threshold_unit: str | None | UndefinedType = UNDEFINED,
unit_attributes: dict | None = None,
attribute_required: bool = False,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=all.
@@ -2256,9 +2302,18 @@ def parametrize_numerical_attribute_condition_above_below_all(
`{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated
state, so the entity carries a unit alongside its tracked
attribute.
attribute_required: When True, `(state, {attribute: None})` is
classified as an *excluded* state (filtered out of the all/any
check by the condition's `_should_include` override) rather
than treated as just-missing. Set this for conditions whose
`_should_include` skips entities lacking the tracked
attribute.
"""
condition_options = condition_options or {}
unit_attributes = unit_attributes or {}
extra_excluded_states = (
[(state, {attribute: None} | unit_attributes)] if attribute_required else None
)
return [
*parametrize_condition_states_all(
@@ -2280,6 +2335,7 @@ def parametrize_numerical_attribute_condition_above_below_all(
(state, {attribute: 10} | unit_attributes),
(state, {attribute: 20} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_all(
@@ -2301,6 +2357,7 @@ def parametrize_numerical_attribute_condition_above_below_all(
(state, {attribute: 90} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
*parametrize_condition_states_all(
@@ -2327,6 +2384,7 @@ def parametrize_numerical_attribute_condition_above_below_all(
(state, {attribute: 80} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
),
]