diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index ab5d03c9ab4..b06c0b285e1 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -14,9 +14,9 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.condition import Condition, make_entity_numerical_condition +from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase HUMIDITY_DOMAIN_SPECS = { CLIMATE_DOMAIN: DomainSpec( @@ -31,8 +31,31 @@ HUMIDITY_DOMAIN_SPECS = { ), } + +class HumidityCondition(EntityNumericalConditionBase): + """Condition for humidity value across multiple domains.""" + + _domain_specs = HUMIDITY_DOMAIN_SPECS + _valid_unit = PERCENTAGE + + def _should_include(self, state: State) -> bool: + """Skip attribute-source entities that lack the humidity attribute. + + Mirrors the humidity trigger: for climate / humidifier / weather + (attribute-based), the entity is filtered when the source attribute + is absent; sensor entities (state-value-based) fall through to the + base impl. + """ + if not super()._should_include(state): + return False + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is None: + return True + return state.attributes.get(domain_spec.value_source) is not None + + CONDITIONS: dict[str, type[Condition]] = { - "is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE), + "is_value": HumidityCondition, } diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py index 3785723cb28..69c22ebdbd3 100644 --- a/homeassistant/components/humidity/trigger.py +++ b/homeassistant/components/humidity/trigger.py @@ -13,12 +13,13 @@ from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, DOMAIN as WEATHER_DOMAIN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerBase, Trigger, - make_entity_numerical_state_changed_trigger, - make_entity_numerical_state_crossed_threshold_trigger, ) HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = { @@ -36,13 +37,46 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = { ), } + +class _HumidityTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for humidity triggers providing entity filtering.""" + + _domain_specs = HUMIDITY_DOMAIN_SPECS + _valid_unit = "%" + + def _should_include(self, state: State) -> bool: + """Skip attribute-source entities that lack the humidity attribute. + + For domains whose tracked value comes from an attribute + (climate / humidifier / weather), require the attribute to be + present; otherwise the all/count check would treat an entity that + cannot report a humidity as a non-match and block behavior=last. + Sensor entities source their value from `state.state`, so they + fall through to the base impl. + """ + if not super()._should_include(state): + return False + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is None: + return True + return state.attributes.get(domain_spec.value_source) is not None + + +class HumidityChangedTrigger( + _HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase +): + """Trigger for humidity value changes across multiple domains.""" + + +class HumidityCrossedThresholdTrigger( + _HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase +): + """Trigger for humidity value crossing a threshold across multiple domains.""" + + TRIGGERS: dict[str, type[Trigger]] = { - "changed": make_entity_numerical_state_changed_trigger( - HUMIDITY_DOMAIN_SPECS, valid_unit="%" - ), - "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - HUMIDITY_DOMAIN_SPECS, valid_unit="%" - ), + "changed": HumidityChangedTrigger, + "crossed_threshold": HumidityCrossedThresholdTrigger, } diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index ea079d87cff..f280e390637 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -179,6 +179,7 @@ async def test_humidity_sensor_condition_behavior_all( "humidity.is_value", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_climate_condition_behavior_any( @@ -215,6 +216,7 @@ async def test_humidity_climate_condition_behavior_any( "humidity.is_value", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_climate_condition_behavior_all( @@ -251,6 +253,7 @@ async def test_humidity_climate_condition_behavior_all( "humidity.is_value", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_humidifier_condition_behavior_any( @@ -287,6 +290,7 @@ async def test_humidity_humidifier_condition_behavior_any( "humidity.is_value", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_humidifier_condition_behavior_all( @@ -323,6 +327,7 @@ async def test_humidity_humidifier_condition_behavior_all( "humidity.is_value", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_weather_condition_behavior_any( @@ -359,6 +364,7 @@ async def test_humidity_weather_condition_behavior_any( "humidity.is_value", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_weather_condition_behavior_all( diff --git a/tests/components/humidity/test_trigger.py b/tests/components/humidity/test_trigger.py index c0d766c50f6..edd303a0da2 100644 --- a/tests/components/humidity/test_trigger.py +++ b/tests/components/humidity/test_trigger.py @@ -240,12 +240,16 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "humidity.changed", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY + "humidity.changed", + HVACMode.AUTO, + CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "humidity.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -284,6 +288,7 @@ async def test_humidity_trigger_climate_behavior_any( "humidity.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -322,6 +327,7 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first( "humidity.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -360,12 +366,16 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "humidity.changed", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY + "humidity.changed", + STATE_ON, + HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "humidity.crossed_threshold", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -404,6 +414,7 @@ async def test_humidity_trigger_humidifier_behavior_any( "humidity.crossed_threshold", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -442,6 +453,7 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( "humidity.crossed_threshold", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -480,12 +492,16 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "humidity.changed", "sunny", ATTR_WEATHER_HUMIDITY + "humidity.changed", + "sunny", + ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "humidity.crossed_threshold", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ], ) @@ -524,6 +540,7 @@ async def test_humidity_trigger_weather_behavior_any( "humidity.crossed_threshold", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ], ) @@ -562,6 +579,7 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first( "humidity.crossed_threshold", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ], )