diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8d55680c86f..f2c65752809 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -176,6 +176,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "text", "update", "vacuum", + "water_heater", "window", } diff --git a/homeassistant/components/water_heater/icons.json b/homeassistant/components/water_heater/icons.json index bc80128c6a3..2f1752a5c77 100644 --- a/homeassistant/components/water_heater/icons.json +++ b/homeassistant/components/water_heater/icons.json @@ -37,5 +37,19 @@ "turn_on": { "service": "mdi:water-boiler" } + }, + "triggers": { + "target_temperature_changed": { + "trigger": "mdi:thermometer" + }, + "target_temperature_crossed_threshold": { + "trigger": "mdi:thermometer" + }, + "turned_off": { + "trigger": "mdi:water-boiler-off" + }, + "turned_on": { + "trigger": "mdi:water-boiler" + } } } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index d5e7d6fadc1..937436c86f2 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted water heaters to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "action_type": { "turn_off": "[%key:common::device_automation::action_type::turn_off%]", @@ -54,6 +58,29 @@ "message": "Operation mode {operation_mode} is not valid for {entity_id}. The operation list is not defined." } }, + "selector": { + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + }, + "trigger_threshold_type": { + "options": { + "above": "Above a value", + "below": "Below a value", + "between": "In a range", + "outside": "Outside a range" + } + } + }, "services": { "set_away_mode": { "description": "Turns away mode on/off.", @@ -98,5 +125,71 @@ "name": "[%key:common::action::turn_on%]" } }, - "title": "Water heater" + "title": "Water heater", + "triggers": { + "target_temperature_changed": { + "description": "Triggers after the temperature setpoint of one or more water heaters changes.", + "fields": { + "above": { + "description": "Trigger when the target temperature is above this value.", + "name": "Above" + }, + "below": { + "description": "Trigger when the target temperature is below this value.", + "name": "Below" + }, + "unit": { + "description": "All values will be converted to this unit when evaluating the trigger.", + "name": "Unit of measurement" + } + }, + "name": "Water heater target temperature changed" + }, + "target_temperature_crossed_threshold": { + "description": "Triggers after the temperature setpoint of one or more water heaters crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::water_heater::common::trigger_behavior_description%]", + "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "Lower threshold limit.", + "name": "Lower threshold" + }, + "threshold_type": { + "description": "Type of threshold crossing to trigger on.", + "name": "Threshold type" + }, + "unit": { + "description": "[%key:component::water_heater::triggers::target_temperature_changed::fields::unit::description%]", + "name": "[%key:component::water_heater::triggers::target_temperature_changed::fields::unit::name%]" + }, + "upper_limit": { + "description": "Upper threshold limit.", + "name": "Upper threshold" + } + }, + "name": "Water heater target temperature crossed threshold" + }, + "turned_off": { + "description": "Triggers after one or more water heaters turn off.", + "fields": { + "behavior": { + "description": "[%key:component::water_heater::common::trigger_behavior_description%]", + "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + } + }, + "name": "Water heater turned off" + }, + "turned_on": { + "description": "Triggers after one or more water heaters turn on, regardless of the operation mode.", + "fields": { + "behavior": { + "description": "[%key:component::water_heater::common::trigger_behavior_description%]", + "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + } + }, + "name": "Water heater turned on" + } + } } diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py new file mode 100644 index 00000000000..a08b5a13bef --- /dev/null +++ b/homeassistant/components/water_heater/trigger.py @@ -0,0 +1,58 @@ +"""Provides triggers for water heaters.""" + +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerWithUnitBase, + EntityNumericalStateCrossedThresholdTriggerWithUnitBase, + EntityNumericalStateTriggerWithUnitBase, + Trigger, + make_entity_origin_state_trigger, + make_entity_target_state_trigger, +) +from homeassistant.util.unit_conversion import TemperatureConverter + +from .const import DOMAIN + + +class _WaterHeaterTargetTemperatureTriggerMixin( + EntityNumericalStateTriggerWithUnitBase +): + """Mixin for water heater target temperature triggers with unit conversion.""" + + _base_unit = UnitOfTemperature.CELSIUS + _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _unit_converter = TemperatureConverter + + def _get_entity_unit(self, state: State) -> str | None: + """Get the temperature unit of a water heater entity from its state.""" + # Water heater entities convert temperatures to the system unit via show_temp + return self._hass.config.units.temperature_unit + + +class WaterHeaterTargetTemperatureChangedTrigger( + _WaterHeaterTargetTemperatureTriggerMixin, + EntityNumericalStateChangedTriggerWithUnitBase, +): + """Trigger for water heater target temperature value changes.""" + + +class WaterHeaterTargetTemperatureCrossedThresholdTrigger( + _WaterHeaterTargetTemperatureTriggerMixin, + EntityNumericalStateCrossedThresholdTriggerWithUnitBase, +): + """Trigger for water heater target temperature value crossing a threshold.""" + + +TRIGGERS: dict[str, type[Trigger]] = { + "target_temperature_changed": WaterHeaterTargetTemperatureChangedTrigger, + "target_temperature_crossed_threshold": WaterHeaterTargetTemperatureCrossedThresholdTrigger, + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), + "turned_on": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for water heaters.""" + return TRIGGERS diff --git a/homeassistant/components/water_heater/triggers.yaml b/homeassistant/components/water_heater/triggers.yaml new file mode 100644 index 00000000000..a3d553596b5 --- /dev/null +++ b/homeassistant/components/water_heater/triggers.yaml @@ -0,0 +1,77 @@ +.trigger_common: &trigger_common + target: &trigger_water_heater_target + entity: + domain: water_heater + fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.trigger_threshold_type: &trigger_threshold_type + required: true + default: above + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + +.number_or_entity_temperature: &number_or_entity_temperature + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + entity: + selector: + entity: + filter: + - domain: input_number + unit_of_measurement: + - "°C" + - "°F" + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature + translation_key: number_or_entity + +.trigger_unit_temperature: &trigger_unit_temperature + required: false + selector: + select: + options: + - "°C" + - "°F" + +turned_off: *trigger_common +turned_on: *trigger_common + +target_temperature_changed: + target: *trigger_water_heater_target + fields: + above: *number_or_entity_temperature + below: *number_or_entity_temperature + unit: *trigger_unit_temperature + +target_temperature_crossed_threshold: + target: *trigger_water_heater_target + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity_temperature + upper_limit: *number_or_entity_temperature + unit: *trigger_unit_temperature diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py new file mode 100644 index 00000000000..0d431db503a --- /dev/null +++ b/tests/components/water_heater/test_trigger.py @@ -0,0 +1,327 @@ +"""Test water heater trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature +from homeassistant.core import HomeAssistant, ServiceCall + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_numerical_attribute_changed_trigger_states, + parametrize_numerical_attribute_crossed_threshold_trigger_states, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + +ALL_ON_STATES = [ + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_ON, + STATE_PERFORMANCE, +] + +_TEMPERATURE_TRIGGER_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} + + +@pytest.fixture +async def target_water_heaters(hass: HomeAssistant) -> list[str]: + """Create multiple water heater entities associated with different targets.""" + return await target_entities(hass, "water_heater") + + +@pytest.mark.parametrize( + "trigger_key", + [ + "water_heater.target_temperature_changed", + "water_heater.target_temperature_crossed_threshold", + "water_heater.turned_off", + "water_heater.turned_on", + ], +) +async def test_water_heater_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the water heater triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="water_heater.turned_off", + target_states=[STATE_OFF], + other_states=ALL_ON_STATES, + ), + *parametrize_trigger_states( + trigger="water_heater.turned_on", + target_states=ALL_ON_STATES, + other_states=[STATE_OFF], + ), + ], +) +async def test_water_heater_state_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: 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 that the water heater state trigger fires when any water heater state changes to a specific state.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_changed_trigger_states( + "water_heater.target_temperature_changed", + STATE_ECO, + ATTR_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "water_heater.target_temperature_crossed_threshold", + STATE_ECO, + ATTR_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_water_heater_state_attribute_trigger_behavior_any( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: 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 that the water heater target temperature attribute triggers fire when any water heater's target temperature changes or crosses a threshold.""" + await assert_trigger_behavior_any( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="water_heater.turned_off", + target_states=[STATE_OFF], + other_states=ALL_ON_STATES, + ), + *parametrize_trigger_states( + trigger="water_heater.turned_on", + target_states=ALL_ON_STATES, + other_states=[STATE_OFF], + ), + ], +) +async def test_water_heater_state_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: 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 that the water heater state trigger fires when the first water heater changes to a specific state.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "water_heater.target_temperature_crossed_threshold", + STATE_ECO, + ATTR_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_water_heater_state_attribute_trigger_behavior_first( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[tuple[tuple[str, dict], int]], +) -> None: + """Test that the water heater attribute threshold trigger fires when the first water heater's target temperature crosses the configured threshold.""" + await assert_trigger_behavior_first( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="water_heater.turned_off", + target_states=[STATE_OFF], + other_states=ALL_ON_STATES, + ), + *parametrize_trigger_states( + trigger="water_heater.turned_on", + target_states=ALL_ON_STATES, + other_states=[STATE_OFF], + ), + ], +) +async def test_water_heater_state_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: 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 that the water heater state trigger fires when the last water heater changes to a specific state.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("water_heater"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "water_heater.target_temperature_crossed_threshold", + STATE_ECO, + ATTR_TEMPERATURE, + trigger_options=_TEMPERATURE_TRIGGER_OPTIONS, + ), + ], +) +async def test_water_heater_state_attribute_trigger_behavior_last( + hass: HomeAssistant, + service_calls: list[ServiceCall], + target_water_heaters: list[str], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[tuple[tuple[str, dict], int]], +) -> None: + """Test that the water heater trigger fires when the last water heater's target temperature crosses the configured threshold.""" + await assert_trigger_behavior_last( + hass, + service_calls=service_calls, + target_entities=target_water_heaters, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + )