From c8f7d9dd420e408f19192d8bebcfe877d476472e Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:16:45 +0100 Subject: [PATCH] Add moisture conditions (#166470) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/automation/__init__.py | 1 + homeassistant/components/moisture/__init__.py | 2 +- .../components/moisture/condition.py | 44 +++ .../components/moisture/conditions.yaml | 52 +++ homeassistant/components/moisture/icons.json | 11 + .../components/moisture/strings.json | 48 +++ tests/components/moisture/test_condition.py | 295 ++++++++++++++++++ 7 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/moisture/condition.py create mode 100644 homeassistant/components/moisture/conditions.yaml create mode 100644 tests/components/moisture/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 13992563d22..0359043aa60 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -136,6 +136,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "light", "lock", "media_player", + "moisture", "motion", "occupancy", "person", diff --git a/homeassistant/components/moisture/__init__.py b/homeassistant/components/moisture/__init__.py index 328d66e6942..a90352418a2 100644 --- a/homeassistant/components/moisture/__init__.py +++ b/homeassistant/components/moisture/__init__.py @@ -1,4 +1,4 @@ -"""Integration for moisture triggers.""" +"""Integration for moisture triggers and conditions.""" from __future__ import annotations diff --git a/homeassistant/components/moisture/condition.py b/homeassistant/components/moisture/condition.py new file mode 100644 index 00000000000..aaeee6359e1 --- /dev/null +++ b/homeassistant/components/moisture/condition.py @@ -0,0 +1,44 @@ +"""Provides conditions for moisture.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import ( + Condition, + make_entity_numerical_condition, + make_entity_state_condition, +) + +_MOISTURE_BINARY_DOMAIN_SPECS = { + BINARY_SENSOR_DOMAIN: DomainSpec( + device_class=BinarySensorDeviceClass.MOISTURE, + ) +} + +_MOISTURE_NUMERICAL_DOMAIN_SPECS = { + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), +} + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(_MOISTURE_BINARY_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition( + _MOISTURE_BINARY_DOMAIN_SPECS, STATE_OFF + ), + "is_value": make_entity_numerical_condition( + _MOISTURE_NUMERICAL_DOMAIN_SPECS, PERCENTAGE + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for moisture.""" + return CONDITIONS diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml new file mode 100644 index 00000000000..03818912730 --- /dev/null +++ b/homeassistant/components/moisture/conditions.yaml @@ -0,0 +1,52 @@ +.detected_condition_common: &detected_condition_common + target: + entity: + - domain: binary_sensor + device_class: moisture + fields: + behavior: &condition_behavior + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +.number_or_entity: &number_or_entity + required: false + selector: + choose: + choices: + number: + selector: + number: + unit_of_measurement: "%" + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: "%" + - domain: number + device_class: moisture + - domain: sensor + device_class: moisture + translation_key: number_or_entity + +is_detected: *detected_condition_common + +is_not_detected: *detected_condition_common + +is_value: + target: + entity: + - domain: sensor + device_class: moisture + - domain: number + device_class: moisture + fields: + behavior: *condition_behavior + above: *number_or_entity + below: *number_or_entity diff --git a/homeassistant/components/moisture/icons.json b/homeassistant/components/moisture/icons.json index a18ccc1af93..5c1d827eccd 100644 --- a/homeassistant/components/moisture/icons.json +++ b/homeassistant/components/moisture/icons.json @@ -1,4 +1,15 @@ { + "conditions": { + "is_detected": { + "condition": "mdi:water" + }, + "is_not_detected": { + "condition": "mdi:water-off" + }, + "is_value": { + "condition": "mdi:water-percent" + } + }, "triggers": { "changed": { "trigger": "mdi:water-percent" diff --git a/homeassistant/components/moisture/strings.json b/homeassistant/components/moisture/strings.json index 8d764e94245..aa5727f9e8f 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -1,9 +1,57 @@ { "common": { + "condition_behavior_description": "How the state should match on the targeted entities.", + "condition_behavior_name": "Behavior", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_detected": { + "description": "Tests if one or more moisture sensors are detecting moisture.", + "fields": { + "behavior": { + "description": "[%key:component::moisture::common::condition_behavior_description%]", + "name": "[%key:component::moisture::common::condition_behavior_name%]" + } + }, + "name": "Moisture is detected" + }, + "is_not_detected": { + "description": "Tests if one or more moisture sensors are not detecting moisture.", + "fields": { + "behavior": { + "description": "[%key:component::moisture::common::condition_behavior_description%]", + "name": "[%key:component::moisture::common::condition_behavior_name%]" + } + }, + "name": "Moisture is not detected" + }, + "is_value": { + "description": "Tests the moisture level of one or more entities.", + "fields": { + "above": { + "description": "Require the moisture level to be above this value.", + "name": "Above" + }, + "behavior": { + "description": "[%key:component::moisture::common::condition_behavior_description%]", + "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "below": { + "description": "Require the moisture level to be below this value.", + "name": "Below" + } + }, + "name": "Moisture level" + } + }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "number_or_entity": { "choices": { "entity": "Entity", diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py new file mode 100644 index 00000000000..e56834fb5d1 --- /dev/null +++ b/tests/components/moisture/test_condition.py @@ -0,0 +1,295 @@ +"""Test moisture conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_numerical_condition_above_below_all, + parametrize_numerical_condition_above_below_any, + parametrize_target_entities, + target_entities, +) + +_MOISTURE_UNIT_ATTRS = {ATTR_UNIT_OF_MEASUREMENT: "%"} + + +@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") + + +@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") + + +@pytest.fixture +async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple number entities associated with different targets.""" + return await target_entities(hass, "number") + + +@pytest.mark.parametrize( + "condition", + [ + "moisture.is_detected", + "moisture.is_not_detected", + "moisture.is_value", + ], +) +async def test_moisture_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the moisture conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="moisture.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "moisture"}, + ), + *parametrize_condition_states_any( + condition="moisture.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "moisture"}, + ), + ], +) +async def test_moisture_binary_sensor_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test moisture condition for binary_sensor with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="moisture.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "moisture"}, + ), + *parametrize_condition_states_all( + condition="moisture.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "moisture"}, + ), + ], +) +async def test_moisture_binary_sensor_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test moisture condition for binary_sensor with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_condition_above_below_any( + "moisture.is_value", + device_class="moisture", + unit_attributes=_MOISTURE_UNIT_ATTRS, + ), +) +async def test_moisture_sensor_condition_behavior_any( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the moisture sensor condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_condition_above_below_all( + "moisture.is_value", + device_class="moisture", + unit_attributes=_MOISTURE_UNIT_ATTRS, + ), +) +async def test_moisture_sensor_condition_behavior_all( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the moisture sensor condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_condition_above_below_any( + "moisture.is_value", + device_class="moisture", + unit_attributes=_MOISTURE_UNIT_ATTRS, + ), +) +async def test_moisture_number_condition_behavior_any( + hass: HomeAssistant, + target_numbers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the moisture number condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_numbers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_condition_above_below_all( + "moisture.is_value", + device_class="moisture", + unit_attributes=_MOISTURE_UNIT_ATTRS, + ), +) +async def test_moisture_number_condition_behavior_all( + hass: HomeAssistant, + target_numbers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the moisture number condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_numbers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + )