1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-18 07:56:03 +01:00

Add humidity triggers (#165197)

This commit is contained in:
Erik Montnemery
2026-03-09 20:34:26 +01:00
committed by GitHub
parent ce11e66e1f
commit c037dad093
20 changed files with 1204 additions and 50 deletions

2
CODEOWNERS generated
View File

@@ -743,6 +743,8 @@ build.json @home-assistant/supervisor
/tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
/tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/humidity/ @home-assistant/core
/tests/components/humidity/ @home-assistant/core
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/husqvarna_automower/ @Thomas55555

View File

@@ -243,6 +243,7 @@ DEFAULT_INTEGRATIONS = {
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"humidity",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

View File

@@ -146,6 +146,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"fan",
"garage_door",
"humidifier",
"humidity",
"lawn_mower",
"light",
"lock",

View File

@@ -53,16 +53,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_HUMIDITY
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_HUMIDITY
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_TEMPERATURE
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(

View File

@@ -2,24 +2,15 @@
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.helpers.trigger import (
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class CoverTriggerBase(EntityTriggerBase):
"""Base trigger for cover state changes."""

View File

@@ -0,0 +1,17 @@
"""Integration for humidity triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "humidity"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"changed": {
"trigger": "mdi:water-percent"
},
"crossed_threshold": {
"trigger": "mdi:water-percent"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "humidity",
"name": "Humidity",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/humidity",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,68 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above",
"below": "Below",
"between": "Between",
"outside": "Outside"
}
}
},
"title": "Humidity",
"triggers": {
"changed": {
"description": "Triggers when the humidity changes.",
"fields": {
"above": {
"description": "Only trigger when humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when humidity is below this value.",
"name": "Below"
}
},
"name": "Humidity changed"
},
"crossed_threshold": {
"description": "Triggers when the humidity crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
"name": "[%key:component::humidity::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "The lower limit of the threshold.",
"name": "Lower limit"
},
"threshold_type": {
"description": "The type of threshold to use.",
"name": "Threshold type"
},
"upper_limit": {
"description": "The upper limit of the threshold.",
"name": "Upper limit"
}
},
"name": "Humidity crossed threshold"
}
}
}

View File

@@ -0,0 +1,71 @@
"""Provides triggers for humidity."""
from __future__ import annotations
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY,
DOMAIN as CLIMATE_DOMAIN,
)
from homeassistant.components.humidifier import (
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
DOMAIN as HUMIDIFIER_DOMAIN,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _HumidityTriggerMixin(EntityTriggerBase):
"""Mixin for humidity triggers providing entity filtering and value extraction."""
_attributes = {
CLIMATE_DOMAIN: CLIMATE_ATTR_CURRENT_HUMIDITY,
HUMIDIFIER_DOMAIN: HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
SENSOR_DOMAIN: None, # Use state.state
WEATHER_DOMAIN: ATTR_WEATHER_HUMIDITY,
}
_domains = {SENSOR_DOMAIN, CLIMATE_DOMAIN, HUMIDIFIER_DOMAIN, WEATHER_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities: all climate/humidifier/weather, sensor only with device_class humidity."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] != SENSOR_DOMAIN
or get_device_class_or_undefined(self._hass, entity_id)
== SensorDeviceClass.HUMIDITY
}
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for humidity."""
return TRIGGERS

View File

@@ -0,0 +1,64 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
.trigger_target: &trigger_target
entity:
- domain: sensor
device_class: humidity
- domain: climate
- domain: humidifier
- domain: weather
changed:
target: *trigger_target
fields:
above: *number_or_entity
below: *number_or_entity
crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -24,7 +24,7 @@ class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domains = {DOMAIN}
_attribute = ATTR_BRIGHTNESS
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_converter = staticmethod(_convert_uint8_to_percentage)
@@ -35,7 +35,7 @@ class BrightnessCrossedThresholdTrigger(
"""Trigger for brightness crossed threshold."""
_domains = {DOMAIN}
_attribute = ATTR_BRIGHTNESS
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_converter = staticmethod(_convert_uint8_to_percentage)

View File

@@ -73,6 +73,7 @@ from .automation import (
get_relative_description_key,
move_options_fields_to_top_level,
)
from .entity import get_device_class
from .integration_platform import async_process_integration_platforms
from .selector import TargetSelector
from .target import (
@@ -80,7 +81,7 @@ from .target import (
async_track_target_selector_state_change_event,
)
from .template import Template
from .typing import ConfigType, TemplateVarsType
from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType
_LOGGER = logging.getLogger(__name__)
@@ -333,6 +334,16 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
@@ -600,17 +611,29 @@ def _get_numerical_value(
return entity_or_float
class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
"""Trigger for numerical state attribute changes."""
class EntityNumericalStateBase(EntityTriggerBase):
"""Base class for numerical state and state attribute triggers."""
_attributes: dict[str, str | None]
_converter: Callable[[Any], float] = float
def _get_tracked_value(self, state: State) -> Any:
"""Get the tracked numerical value from a state."""
domain = split_entity_id(state.entity_id)[0]
source = self._attributes[domain]
if source is None:
return state.state
return state.attributes.get(source)
class EntityNumericalStateAttributeChangedTriggerBase(EntityNumericalStateBase):
"""Trigger for numerical state and state attribute changes."""
_attribute: str
_schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
_above: None | float | str
_below: None | float | str
_converter: Callable[[Any], float] = float
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
@@ -622,20 +645,18 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.attributes.get(self._attribute) != to_state.attributes.get(
self._attribute
)
return self._get_tracked_value(from_state) != self._get_tracked_value(to_state) # type: ignore[no-any-return]
def is_valid_state(self, state: State) -> bool:
"""Check if the new state attribute matches the expected one."""
# Handle missing or None attribute case first to avoid expensive exceptions
if (_attribute_value := state.attributes.get(self._attribute)) is None:
"""Check if the new state or state attribute matches the expected one."""
# Handle missing or None value case first to avoid expensive exceptions
if (_attribute_value := self._get_tracked_value(state)) is None:
return False
try:
current_value = self._converter(_attribute_value)
except TypeError, ValueError:
# Attribute is not a valid number, don't trigger
# Value is not a valid number, don't trigger
return False
if self._above is not None:
@@ -709,22 +730,21 @@ NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.exten
)
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase):
"""Trigger for numerical state attribute changes.
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(
EntityNumericalStateBase
):
"""Trigger for numerical state and state attribute changes.
This trigger only fires when the observed attribute changes from not within to within
the defined threshold.
"""
_attribute: str
_schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
_lower_limit: float | str | None = None
_upper_limit: float | str | None = None
_threshold_type: ThresholdType
_converter: Callable[[Any], float] = float
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
@@ -755,14 +775,14 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase
# Entity not found or invalid number, don't trigger
return False
# Handle missing or None attribute case first to avoid expensive exceptions
if (_attribute_value := state.attributes.get(self._attribute)) is None:
# Handle missing or None value case first to avoid expensive exceptions
if (_attribute_value := self._get_tracked_value(state)) is None:
return False
try:
current_value = self._converter(_attribute_value)
except TypeError, ValueError:
# Attribute is not a valid number, don't trigger
# Value is not a valid number, don't trigger
return False
# Note: We do not need to check for lower_limit/upper_limit being None here
@@ -828,29 +848,29 @@ def make_entity_origin_state_trigger(
def make_entity_numerical_state_attribute_changed_trigger(
domain: str, attribute: str
domains: set[str], attributes: dict[str, str | None]
) -> type[EntityNumericalStateAttributeChangedTriggerBase]:
"""Create a trigger for numerical state attribute change."""
class CustomTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for numerical state attribute changes."""
_domains = {domain}
_attribute = attribute
_domains = domains
_attributes = attributes
return CustomTrigger
def make_entity_numerical_state_attribute_crossed_threshold_trigger(
domain: str, attribute: str
domains: set[str], attributes: dict[str, str | None]
) -> type[EntityNumericalStateAttributeCrossedThresholdTriggerBase]:
"""Create a trigger for numerical state attribute change."""
class CustomTrigger(EntityNumericalStateAttributeCrossedThresholdTriggerBase):
"""Trigger for numerical state attribute changes."""
_domains = {domain}
_attribute = attribute
_domains = domains
_attributes = attributes
return CustomTrigger

View File

@@ -87,6 +87,7 @@ NO_IOT_CLASS = [
"homeassistant_hardware",
"homeassistant_sky_connect",
"homeassistant_yellow",
"humidity",
"image_upload",
"input_boolean",
"input_button",

View File

@@ -2121,6 +2121,7 @@ NO_QUALITY_SCALE = [
"homeassistant_hardware",
"homeassistant_sky_connect",
"homeassistant_yellow",
"humidity",
"image_upload",
"input_boolean",
"input_button",

View File

@@ -635,6 +635,111 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
]
def parametrize_numerical_state_value_changed_trigger_states(
trigger: str, device_class: str
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical state-value changed triggers.
Unlike parametrize_numerical_attribute_changed_trigger_states, this is for
entities where the tracked numerical value is in state.state (e.g. sensor
entities), not in an attribute.
"""
from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415
additional_attributes = {ATTR_DEVICE_CLASS: device_class}
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={},
target_states=["0", "50", "100"],
other_states=["none"],
additional_attributes=additional_attributes,
retrigger_on_target_state=True,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=["50", "100"],
other_states=["none", "0"],
additional_attributes=additional_attributes,
retrigger_on_target_state=True,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=["0", "50"],
other_states=["none", "100"],
additional_attributes=additional_attributes,
retrigger_on_target_state=True,
trigger_from_none=False,
),
]
def parametrize_numerical_state_value_crossed_threshold_trigger_states(
trigger: str, device_class: str
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical state-value crossed threshold triggers.
Unlike parametrize_numerical_attribute_crossed_threshold_trigger_states,
this is for entities where the tracked numerical value is in state.state
(e.g. sensor entities), not in an attribute.
"""
from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415
additional_attributes = {ATTR_DEVICE_CLASS: device_class}
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=["50", "60"],
other_states=["none", "0", "100"],
additional_attributes=additional_attributes,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=["0", "100"],
other_states=["none", "50", "60"],
additional_attributes=additional_attributes,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
},
target_states=["50", "100"],
other_states=["none", "0"],
additional_attributes=additional_attributes,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
},
target_states=["0", "50"],
other_states=["none", "100"],
additional_attributes=additional_attributes,
trigger_from_none=False,
),
]
async def arm_trigger(
hass: HomeAssistant,
trigger: str,

View File

@@ -0,0 +1 @@
"""Tests for the humidity integration."""

View File

@@ -0,0 +1,791 @@
"""Test humidity trigger."""
from typing import Any
import pytest
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY,
HVACMode,
)
from homeassistant.components.humidifier import (
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
)
from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_numerical_attribute_changed_trigger_states,
parametrize_numerical_attribute_crossed_threshold_trigger_states,
parametrize_numerical_state_value_changed_trigger_states,
parametrize_numerical_state_value_crossed_threshold_trigger_states,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> list[str]:
"""Create multiple sensor entities associated with different targets."""
return (await target_entities(hass, "sensor"))["included"]
@pytest.fixture
async def target_climates(hass: HomeAssistant) -> list[str]:
"""Create multiple climate entities associated with different targets."""
return (await target_entities(hass, "climate"))["included"]
@pytest.fixture
async def target_humidifiers(hass: HomeAssistant) -> list[str]:
"""Create multiple humidifier entities associated with different targets."""
return (await target_entities(hass, "humidifier"))["included"]
@pytest.fixture
async def target_weathers(hass: HomeAssistant) -> list[str]:
"""Create multiple weather entities associated with different targets."""
return (await target_entities(hass, "weather"))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"humidity.changed",
"humidity.crossed_threshold",
],
)
async def test_humidity_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the humidity triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
# --- Sensor domain tests (value in state.state) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"humidity.changed", "humidity"
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"humidity.crossed_threshold", "humidity"
),
],
)
async def test_humidity_trigger_sensor_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sensors: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity trigger fires for sensor entities with device_class humidity."""
other_entity_ids = set(target_sensors) - {entity_id}
for eid in target_sensors:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"humidity.crossed_threshold", "humidity"
),
],
)
async def test_humidity_trigger_sensor_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sensors: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires on the first sensor state change."""
other_entity_ids = set(target_sensors) - {entity_id}
for eid in target_sensors:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"humidity.crossed_threshold", "humidity"
),
],
)
async def test_humidity_trigger_sensor_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sensors: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires when the last sensor changes state."""
other_entity_ids = set(target_sensors) - {entity_id}
for eid in target_sensors:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# --- Climate domain tests (value in current_humidity attribute) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"humidity.changed", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
),
],
)
async def test_humidity_trigger_climate_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity trigger fires for climate entities."""
other_entity_ids = set(target_climates) - {entity_id}
for eid in target_climates:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
),
],
)
async def test_humidity_trigger_climate_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires on the first climate state change."""
other_entity_ids = set(target_climates) - {entity_id}
for eid in target_climates:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_HUMIDITY,
),
],
)
async def test_humidity_trigger_climate_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires when the last climate changes state."""
other_entity_ids = set(target_climates) - {entity_id}
for eid in target_climates:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# --- Humidifier domain tests (value in current_humidity attribute) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("humidifier"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"humidity.changed", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
],
)
async def test_humidity_trigger_humidifier_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_humidifiers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity trigger fires for humidifier entities."""
other_entity_ids = set(target_humidifiers) - {entity_id}
for eid in target_humidifiers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("humidifier"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
],
)
async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_humidifiers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires on the first humidifier state change."""
other_entity_ids = set(target_humidifiers) - {entity_id}
for eid in target_humidifiers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("humidifier"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
STATE_ON,
HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
],
)
async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_humidifiers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires when the last humidifier changes state."""
other_entity_ids = set(target_humidifiers) - {entity_id}
for eid in target_humidifiers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# --- Weather domain tests (value in humidity attribute) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("weather"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"humidity.changed", "sunny", ATTR_WEATHER_HUMIDITY
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
"sunny",
ATTR_WEATHER_HUMIDITY,
),
],
)
async def test_humidity_trigger_weather_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_weathers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity trigger fires for weather entities."""
other_entity_ids = set(target_weathers) - {entity_id}
for eid in target_weathers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("weather"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
"sunny",
ATTR_WEATHER_HUMIDITY,
),
],
)
async def test_humidity_trigger_weather_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_weathers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires on the first weather state change."""
other_entity_ids = set(target_weathers) - {entity_id}
for eid in target_weathers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("weather"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidity.crossed_threshold",
"sunny",
ATTR_WEATHER_HUMIDITY,
),
],
)
async def test_humidity_trigger_weather_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_weathers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test humidity crossed_threshold trigger fires when the last weather changes state."""
other_entity_ids = set(target_weathers) - {entity_id}
for eid in target_weathers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
)
for state in states[1:]:
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# --- Device class exclusion test ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
(
"trigger_key",
"trigger_options",
"sensor_initial",
"sensor_target",
),
[
(
"humidity.changed",
{},
"50",
"60",
),
(
"humidity.crossed_threshold",
{"threshold_type": "above", "lower_limit": 10},
"5",
"50",
),
],
)
async def test_humidity_trigger_excludes_non_humidity_sensor(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
trigger_options: dict[str, Any],
sensor_initial: str,
sensor_target: str,
) -> None:
"""Test humidity trigger does not fire for sensor entities without device_class humidity."""
entity_id_humidity = "sensor.test_humidity"
entity_id_temperature = "sensor.test_temperature"
# Set initial states
hass.states.async_set(
entity_id_humidity, sensor_initial, {ATTR_DEVICE_CLASS: "humidity"}
)
hass.states.async_set(
entity_id_temperature, sensor_initial, {ATTR_DEVICE_CLASS: "temperature"}
)
await hass.async_block_till_done()
await arm_trigger(
hass,
trigger_key,
trigger_options,
{
CONF_ENTITY_ID: [
entity_id_humidity,
entity_id_temperature,
]
},
)
# Humidity sensor changes - should trigger
hass.states.async_set(
entity_id_humidity, sensor_target, {ATTR_DEVICE_CLASS: "humidity"}
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_humidity
service_calls.clear()
# Temperature sensor changes - should NOT trigger (wrong device class)
hass.states.async_set(
entity_id_temperature, sensor_target, {ATTR_DEVICE_CLASS: "temperature"}
)
await hass.async_block_till_done()
assert len(service_calls) == 0

View File

@@ -1249,7 +1249,7 @@ async def test_numerical_state_attribute_changed_trigger_config_validation(
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"test_trigger": make_entity_numerical_state_attribute_changed_trigger(
"test", "test_attribute"
{"test"}, {"test": "test_attribute"}
),
}
@@ -1277,7 +1277,7 @@ async def test_numerical_state_attribute_changed_error_handling(
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"attribute_changed": make_entity_numerical_state_attribute_changed_trigger(
"test", "test_attribute"
{"test"}, {"test": "test_attribute"}
),
}
@@ -1559,7 +1559,7 @@ async def test_numerical_state_attribute_crossed_threshold_trigger_config_valida
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"test_trigger": make_entity_numerical_state_attribute_crossed_threshold_trigger(
"test", "test_attribute"
{"test"}, {"test": "test_attribute"}
),
}

View File

@@ -44,6 +44,7 @@
'homeassistant.scene',
'http',
'humidifier',
'humidity',
'image',
'image_processing',
'image_upload',
@@ -143,6 +144,7 @@
'homeassistant.scene',
'http',
'humidifier',
'humidity',
'image',
'image_processing',
'image_upload',