Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions homeassistant/components/climate/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
Expand Down Expand Up @@ -75,6 +76,32 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""


class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""

_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
)


class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""


class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""


TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
Expand All @@ -83,14 +110,8 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
Expand Down
22 changes: 1 addition & 21 deletions homeassistant/components/media_player/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,7 @@ def _should_include(self, state: State) -> bool:
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return state.state not in self._excluded_states and self._has_volume_attributes(
state
)

def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all mutable entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)

def count_matches(self, entity_ids: set[str]) -> int:
"""Count matching mutable entities."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
return super()._should_include(state) and self._has_volume_attributes(state)

def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
Expand Down
72 changes: 46 additions & 26 deletions homeassistant/helpers/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,23 +397,36 @@ def is_valid_transition(self, from_state: State, to_state: State) -> bool:
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""

def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.

def count_matches(self, entity_ids: set[str]) -> int:
"""Count the number of entity states that match."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
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 trigger relies on (e.g. a missing volume_level attribute).
"""
return state.state not in self._excluded_states

def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
"""Return (matches, included) for the entity set.

`matches` is the number of entities that pass `_should_include` AND
`is_valid_state`. `included` is the number that pass
`_should_include` (i.e. are visible to the all/count check at all).
Callers can use the pair to distinguish vacuous truth
(`included == 0`) from a genuine all-match
(`matches == included > 0`).
"""
matches = 0
included = 0
for entity_id in entity_ids:
state = self._hass.states.get(entity_id)
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state):
matches += 1
return matches, included

@override
async def async_attach_runner(
Expand Down Expand Up @@ -445,14 +458,20 @@ def state_still_valid(
For behavior first/last, checks the combined state.
"""
if behavior == BEHAVIOR_LAST:
return self.check_all_match(
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
# Require at least one included entity to avoid keeping
# the timer alive when every targeted entity has been
# filtered out since it started — a vacuous all-match
# (`included == 0`) would otherwise let the action fire
# after `for:` even though no entity still matches.
return included > 0 and matches == included
if behavior == BEHAVIOR_FIRST:
return (
self.count_matches(target_state_change_data.targeted_entity_ids)
>= 1
matches, _included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
return matches >= 1
# Behavior any: check the individual entity's state
if not to_state:
return False
Expand All @@ -470,18 +489,19 @@ def state_still_valid(
return

if behavior == BEHAVIOR_LAST:
if not self.check_all_match(
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
):
)
if matches != included:
return
elif behavior == BEHAVIOR_FIRST:
# Note: It's enough to test for exactly 1 match here because if there
# were previously 2 matches the transition would not be valid and we
# would have returned already.
if (
self.count_matches(target_state_change_data.targeted_entity_ids)
!= 1
):
matches, _ = self.count_matches(
target_state_change_data.targeted_entity_ids
)
if matches != 1:
return

@callback
Expand Down
20 changes: 16 additions & 4 deletions tests/components/climate/test_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ async def test_climate_state_trigger_behavior_any(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_changed",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_temperature_changed",
Expand All @@ -227,7 +230,10 @@ async def test_climate_state_trigger_behavior_any(
threshold_unit=UnitOfTemperature.CELSIUS,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
Expand Down Expand Up @@ -342,7 +348,10 @@ async def test_climate_state_trigger_behavior_first(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
Expand Down Expand Up @@ -457,7 +466,10 @@ async def test_climate_state_trigger_behavior_last(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
Expand Down
Loading
Loading