1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add some water heater triggers (#164864)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Robert Resch
2026-03-24 11:59:33 +01:00
committed by GitHub
parent 580ae1e81b
commit 0b13274271
6 changed files with 571 additions and 1 deletions

View File

@@ -176,6 +176,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"text",
"update",
"vacuum",
"water_heater",
"window",
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)