From e48779cbf95ea7d8124a688e54c486e2847b7be8 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:31:59 -0400 Subject: [PATCH] Add climate platform to template integration (#173033) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/template/climate.py | 897 ++++++ homeassistant/components/template/config.py | 6 + .../components/template/config_flow.py | 64 + homeassistant/components/template/const.py | 1 + homeassistant/components/template/entity.py | 19 + .../components/template/strings.json | 91 +- .../components/template/validators.py | 39 + .../template/snapshots/test_climate.ambr | 24 + tests/components/template/test_climate.py | 2451 +++++++++++++++++ tests/components/template/test_config_flow.py | 57 + 10 files changed, 3645 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/template/climate.py create mode 100644 tests/components/template/snapshots/test_climate.ambr create mode 100644 tests/components/template/test_climate.py diff --git a/homeassistant/components/template/climate.py b/homeassistant/components/template/climate.py new file mode 100644 index 000000000000..082555df37d6 --- /dev/null +++ b/homeassistant/components/template/climate.py @@ -0,0 +1,897 @@ +"""Support for Template climates.""" + +from collections.abc import Callable +import contextlib +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from typing import TYPE_CHECKING, Any, Self, override + +import probatio + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + ENTITY_ID_FORMAT, + ClimateEntity, + ClimateEntityCapabilityAttribute, + ClimateEntityFeature, + ClimateEntityStateAttribute, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.unit_conversion import TemperatureConverter + +from . import TriggerUpdateCoordinator, validators as tcv +from .const import DOMAIN +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .schemas import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, + make_template_entity_common_schema, +) +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +DEFAULT_NAME = "Template Climate" + +CONF_CURRENT_HUMIDITY = "current_humidity" +CONF_CURRENT_TEMPERATURE = "current_temperature" +CONF_FAN_MODE = "fan_mode" +CONF_FAN_MODES = "fan_modes" +CONF_HVAC_ACTION = "hvac_action" +CONF_HVAC_MODE = "hvac_mode" +CONF_HVAC_MODES = "hvac_modes" +CONF_MAX_HUMIDITY = "max_humidity" +CONF_MAX_TEMPERATURE = "max_temperature" +CONF_MIN_HUMIDITY = "min_humidity" +CONF_MIN_TEMPERATURE = "min_temperature" +CONF_PRECISION = "precision" +CONF_PRESET_MODE = "preset_mode" +CONF_PRESET_MODES = "preset_modes" +CONF_SWING_HORIZONTAL_MODE = "swing_horizontal_mode" +CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" +CONF_SWING_MODE = "swing_mode" +CONF_SWING_MODES = "swing_modes" +CONF_TARGET_HUMIDITY = "target_humidity" +CONF_TARGET_HUMIDITY_STEP = "target_humidity_step" +CONF_TARGET_TEMPERATURE = "target_temperature" +CONF_TARGET_TEMPERATURE_HIGH = "target_temperature_high" +CONF_TARGET_TEMPERATURE_LOW = "target_temperature_low" +CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" + +SET_FAN_MODE_ACTION = "set_fan_mode" +SET_HUMIDITY_ACTION = "set_humidity" +SET_HVAC_MODE_ACTION = "set_hvac_mode" +SET_PRESET_MODE_ACTION = "set_preset_mode" +SET_SWING_HORIZONTAL_MODE_ACTION = "set_swing_horizontal_mode" +SET_SWING_MODE_ACTION = "set_swing_mode" +SET_TEMPERATURE_ACTION = "set_temperature" + +SCRIPT_FIELDS = ( + SET_FAN_MODE_ACTION, + SET_HUMIDITY_ACTION, + SET_HVAC_MODE_ACTION, + SET_PRESET_MODE_ACTION, + SET_SWING_HORIZONTAL_MODE_ACTION, + SET_SWING_MODE_ACTION, + SET_TEMPERATURE_ACTION, +) + + +_BLOCKED_ATTRIBUTES = tcv.BlockedTemplateAttributes( + attributes=(ClimateEntityCapabilityAttribute, ClimateEntityStateAttribute) +) + + +def _round_to_step(value: float, step: float) -> float: + """Round a temperature to the nearest step using half-up midpoint handling.""" + decimal_value = Decimal(str(value)) + decimal_step = Decimal(str(step)) + return float( + (decimal_value / decimal_step).quantize(0, ROUND_HALF_UP) * decimal_step + ) + + +CLIMATE_COMMON_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_CURRENT_HUMIDITY): cv.template, + probatio.Optional(CONF_CURRENT_TEMPERATURE): cv.template, + probatio.Optional(CONF_FAN_MODE): cv.template, + probatio.Optional(CONF_FAN_MODES): cv.template, + probatio.Optional(CONF_HVAC_ACTION): cv.template, + probatio.Optional(CONF_HVAC_MODE): cv.template, + probatio.Required(CONF_HVAC_MODES): cv.template, + probatio.Optional(CONF_MAX_HUMIDITY): probatio.Coerce(int), + probatio.Optional(CONF_MAX_TEMPERATURE): probatio.Coerce(float), + probatio.Optional(CONF_MIN_HUMIDITY): probatio.Coerce(int), + probatio.Optional(CONF_MIN_TEMPERATURE): probatio.Coerce(float), + probatio.Optional(CONF_PRECISION): probatio.Any( + PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE + ), + probatio.Optional(CONF_PRESET_MODE): cv.template, + probatio.Optional(CONF_PRESET_MODES): cv.template, + probatio.Optional(CONF_SWING_MODE): cv.template, + probatio.Optional(CONF_SWING_MODES): cv.template, + probatio.Optional(CONF_SWING_HORIZONTAL_MODE): cv.template, + probatio.Optional(CONF_SWING_HORIZONTAL_MODES): cv.template, + probatio.Optional(CONF_TARGET_HUMIDITY): cv.template, + probatio.Optional(CONF_TARGET_HUMIDITY_STEP): probatio.All( + probatio.Coerce(int), probatio.Range(min=1) + ), + probatio.Inclusive( + CONF_TARGET_TEMPERATURE_HIGH, "temperature_limits" + ): cv.template, + probatio.Inclusive( + CONF_TARGET_TEMPERATURE_LOW, "temperature_limits" + ): cv.template, + probatio.Optional(CONF_TARGET_TEMPERATURE_STEP): probatio.All( + probatio.Coerce(float), probatio.Range(min=PRECISION_TENTHS) + ), + probatio.Optional(CONF_TARGET_TEMPERATURE): cv.template, + probatio.Optional(CONF_TEMPERATURE_UNIT): probatio.In( + TemperatureConverter.VALID_UNITS + ), + probatio.Optional(SET_FAN_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_HUMIDITY_ACTION): cv.SCRIPT_SCHEMA, + probatio.Required(SET_HVAC_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_SWING_HORIZONTAL_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_SWING_MODE_ACTION): cv.SCRIPT_SCHEMA, + probatio.Optional(SET_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + }, +) + +_CLIMATE_VALIDATION = ( + tcv.inclusive_group("fan_mode", CONF_FAN_MODE, CONF_FAN_MODES, SET_FAN_MODE_ACTION), + tcv.inclusive_group( + "preset_mode", CONF_PRESET_MODE, CONF_PRESET_MODES, SET_PRESET_MODE_ACTION + ), + tcv.inclusive_group( + "swing_mode", CONF_SWING_MODE, CONF_SWING_MODES, SET_SWING_MODE_ACTION + ), + tcv.inclusive_group( + "swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE, + CONF_SWING_HORIZONTAL_MODES, + SET_SWING_HORIZONTAL_MODE_ACTION, + ), + tcv.requires_option(CONF_TARGET_HUMIDITY, SET_HUMIDITY_ACTION), + tcv.requires_option(CONF_TARGET_TEMPERATURE, SET_TEMPERATURE_ACTION), + tcv.requires_option(CONF_TARGET_TEMPERATURE_HIGH, SET_TEMPERATURE_ACTION), + tcv.requires_option(CONF_TARGET_TEMPERATURE_LOW, SET_TEMPERATURE_ACTION), +) + + +CLIMATE_YAML_SCHEMA = probatio.All( + CLIMATE_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_schema( + CLIMATE_DOMAIN, + DEFAULT_NAME, + _BLOCKED_ATTRIBUTES, + ).schema + ), + *_CLIMATE_VALIDATION, +) + +CLIMATE_CONFIG_ENTRY_SCHEMA = probatio.All( + CLIMATE_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema), + *_CLIMATE_VALIDATION, +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the template climates.""" + await async_setup_template_platform( + hass, + CLIMATE_DOMAIN, + config, + StateClimateEntity, + TriggerClimateEntity, + async_add_entities, + discovery_info, + script_options=SCRIPT_FIELDS, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateClimateEntity, + CLIMATE_CONFIG_ENTRY_SCHEMA, + script_options=SCRIPT_FIELDS, + ) + + +@callback +def async_create_preview_climate( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateClimateEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateClimateEntity, + CLIMATE_CONFIG_ENTRY_SCHEMA, + ) + + +def _string_to_list(result: str) -> list[str]: + for char in "()[] ": + result = result.replace(char, "") + return list(result.split(",")) + + +def hvac_modes_list( + entity: AbstractTemplateClimate, +) -> Callable[[Any], list[HVACMode] | None]: + """Convert the result to a list of numbers that represent hvac modes.""" + + expected = f"expected a list of hvac modes: [{', '.join([str(item) for item in HVACMode])}]" + + def convert(result: Any) -> list[HVACMode] | None: + if tcv.check_result_for_none(result): + return [] + + if isinstance(result, str): + with contextlib.suppress(ValueError): + result = _string_to_list(result) + + if isinstance(result, (list, tuple)) and all( + isinstance(value, str) for value in result + ): + validated = [] + invalid = [] + for item in result: + if item in HVACMode: + validated.append(HVACMode(item)) + else: + invalid.append(item) + + if invalid: + tcv.log_validation_result_error( + entity, CONF_HVAC_MODES, result, expected + ) + + return validated + + tcv.log_validation_result_error(entity, CONF_HVAC_MODES, result, expected) + return [] + + return convert + + +@dataclass(kw_only=True) +class ClimateExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + current_humidity: float | None + current_temperature: float | None + fan_mode: str | None + fan_modes: list[str] | None + hvac_action: HVACAction | None + hvac_mode: HVACMode | None + hvac_modes: list[HVACMode] | None + preset_mode: str | None + preset_modes: list[str] | None + swing_mode: str | None + swing_modes: list[str] | None + swing_horizontal_mode: str | None + swing_horizontal_modes: list[str] | None + target_humidity: float | None = None + target_temperature_high: float | None + target_temperature_low: float | None + target_temperature: float | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "current_humidity": self.current_humidity, + "current_temperature": self.current_temperature, + "fan_mode": self.fan_mode, + "fan_modes": self.fan_modes, + "hvac_action": self.hvac_action.value if self.hvac_action else None, + "hvac_mode": self.hvac_mode.value if self.hvac_mode else None, + "hvac_modes": ( + [mode.value for mode in self.hvac_modes] if self.hvac_modes else None + ), + "preset_mode": self.preset_mode, + "preset_modes": self.preset_modes, + "swing_mode": self.swing_mode, + "swing_modes": self.swing_modes, + "swing_horizontal_mode": self.swing_horizontal_mode, + "swing_horizontal_modes": self.swing_horizontal_modes, + "target_humidity": self.target_humidity, + "target_temperature_high": self.target_temperature_high, + "target_temperature_low": self.target_temperature_low, + "target_temperature": self.target_temperature, + } + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored state from a dict.""" + + try: + hvac_action: HVACAction | None = None + if _hvac_action := restored["hvac_action"]: + hvac_action = HVACAction(_hvac_action) + + hvac_mode: HVACMode | None = None + if _hvac_mode := restored["hvac_mode"]: + hvac_mode = HVACMode(_hvac_mode) + + hvac_modes: list[HVACMode] | None = None + if _hvac_modes := restored["hvac_modes"]: + hvac_modes = [HVACMode(item) for item in _hvac_modes] + + return cls( + current_humidity=restored["current_humidity"], + current_temperature=restored["current_temperature"], + fan_mode=restored["fan_mode"], + fan_modes=restored["fan_modes"], + hvac_action=hvac_action, + hvac_mode=hvac_mode, + hvac_modes=hvac_modes, + preset_mode=restored["preset_mode"], + preset_modes=restored["preset_modes"], + swing_mode=restored["swing_mode"], + swing_modes=restored["swing_modes"], + swing_horizontal_mode=restored["swing_horizontal_mode"], + swing_horizontal_modes=restored["swing_horizontal_modes"], + target_humidity=restored["target_humidity"], + target_temperature_high=restored["target_temperature_high"], + target_temperature_low=restored["target_temperature_low"], + target_temperature=restored["target_temperature"], + ) + except KeyError, ValueError: + return None + + +class AbstractTemplateClimate(AbstractTemplateEntity, ClimateEntity, RestoreEntity): + """Representation of template climate features.""" + + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + _state_option = CONF_HVAC_MODE + _restore_state_extra_data = ClimateExtraStoredData + _restore_state_properties = ("_attr_hvac_mode",) + _blocked_attributes = _BLOCKED_ATTRIBUTES + + # The super init is not called because TemplateEntity + # and TriggerEntity will call + # AbstractTemplateEntity.__init__. This ensures that + # the __init__ on AbstractTemplateEntity is not + # called twice. + def __init__( # pylint: disable=super-init-not-called + self, hass: HomeAssistant, name: str, config: dict[str, Any] + ) -> None: + """Initialize the features.""" + + self._attr_temperature_unit = ( + config.get(CONF_TEMPERATURE_UNIT) or hass.config.units.temperature_unit + ) + self._attr_target_humidity_step = config.get(CONF_TARGET_HUMIDITY_STEP) + self._attr_target_temperature_step = config.get(CONF_TARGET_TEMPERATURE_STEP) + + # Only set these options when it exists in the configuration in order + # to properly use default values set by the upstream class. + for attr, option in ( + ("_attr_max_temp", CONF_MAX_TEMPERATURE), + ("_attr_min_temp", CONF_MIN_TEMPERATURE), + ("_attr_max_humidity", CONF_MAX_HUMIDITY), + ("_attr_min_humidity", CONF_MIN_HUMIDITY), + ("_attr_precision", CONF_PRECISION), + ): + if (option_value := config.get(option)) is not None: + setattr(self, attr, option_value) + + self._attr_hvac_mode = None + self._attr_hvac_modes = [] + self._attr_fan_mode = None + self._attr_fan_modes = None + self._attr_preset_mode = None + self._attr_preset_modes = None + self._attr_swing_mode = None + self._attr_swing_modes = None + self._attr_swing_horizontal_mode = None + self._attr_swing_horizontal_modes = None + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + + self.setup_template( + CONF_HVAC_MODES, + "_attr_hvac_modes", + hvac_modes_list(self), + self._update_hvac_modes, + none_on_template_error=False, + ) + self.setup_state_template( + "_attr_hvac_mode", + tcv.item_in_list(self, CONF_HVAC_MODE, "_attr_hvac_modes", CONF_HVAC_MODES), + self._update_hvac_mode, + ) + self.setup_template( + CONF_HVAC_ACTION, + "_attr_hvac_action", + tcv.strenum(self, CONF_HVAC_ACTION, HVACAction), + ) + self.add_assumed_attribute( + "_attr_hvac_action", CONF_HVAC_ACTION, SET_HVAC_MODE_ACTION + ) + + self.setup_template( + CONF_CURRENT_TEMPERATURE, + "_attr_current_temperature", + tcv.number(self, CONF_CURRENT_TEMPERATURE), + ) + + for option, attr in ( + ( + CONF_TARGET_TEMPERATURE, + "_attr_target_temperature", + ), + (CONF_TARGET_TEMPERATURE_LOW, "_attr_target_temperature_low"), + (CONF_TARGET_TEMPERATURE_HIGH, "_attr_target_temperature_high"), + ): + self.setup_template( + option, + attr, + tcv.number(self, option, self.min_temp, self.max_temp), + on_update=self._update_target_temperature(attr), + ) + self.add_assumed_attribute(attr, option, SET_TEMPERATURE_ACTION) + + self.setup_template( + CONF_TARGET_HUMIDITY, + "_attr_target_humidity", + tcv.number( + self, + CONF_TARGET_HUMIDITY, + self._attr_min_humidity, + self._attr_max_humidity, + int, + ), + self._update_target_humidity, + ) + self.add_assumed_attribute( + "_attr_target_humidity", CONF_TARGET_HUMIDITY, SET_HUMIDITY_ACTION + ) + self.setup_template( + CONF_CURRENT_HUMIDITY, + "_attr_current_humidity", + tcv.number(self, CONF_CURRENT_HUMIDITY, 0, 100, int), + ) + + self.setup_template( + CONF_FAN_MODES, + "_attr_fan_modes", + tcv.list_of_strings(self, CONF_FAN_MODES), + ) + self.setup_template( + CONF_FAN_MODE, + "_attr_fan_mode", + tcv.item_in_list(self, CONF_FAN_MODE, "_attr_fan_modes", CONF_FAN_MODES), + ) + self.add_assumed_attribute("_attr_fan_mode", CONF_FAN_MODE, SET_FAN_MODE_ACTION) + + self.setup_template( + CONF_SWING_MODES, + "_attr_swing_modes", + tcv.list_of_strings(self, CONF_SWING_MODES), + ) + self.setup_template( + CONF_SWING_MODE, + "_attr_swing_mode", + tcv.item_in_list( + self, CONF_SWING_MODE, "_attr_swing_modes", CONF_SWING_MODES + ), + ) + self.add_assumed_attribute( + "_attr_swing_mode", CONF_SWING_MODE, SET_SWING_MODE_ACTION + ) + + self.setup_template( + CONF_SWING_HORIZONTAL_MODES, + "_attr_swing_horizontal_modes", + tcv.list_of_strings(self, CONF_SWING_HORIZONTAL_MODES), + ) + self.setup_template( + CONF_SWING_HORIZONTAL_MODE, + "_attr_swing_horizontal_mode", + tcv.item_in_list( + self, + CONF_SWING_HORIZONTAL_MODE, + "_attr_swing_horizontal_modes", + CONF_SWING_HORIZONTAL_MODES, + ), + ) + self.add_assumed_attribute( + "_attr_swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE, + SET_SWING_HORIZONTAL_MODE_ACTION, + ) + + self.setup_template( + CONF_PRESET_MODES, + "_attr_preset_modes", + tcv.list_of_strings(self, CONF_PRESET_MODES), + ) + self.setup_template( + CONF_PRESET_MODE, + "_attr_preset_mode", + tcv.item_in_list( + self, + CONF_PRESET_MODE, + "_attr_preset_modes", + CONF_PRESET_MODES, + ), + ) + self.add_assumed_attribute( + "_attr_preset_mode", CONF_PRESET_MODE, SET_PRESET_MODE_ACTION + ) + + self._attr_supported_features = ClimateEntityFeature(0) + for action_id, supported_feature in ( + (SET_FAN_MODE_ACTION, ClimateEntityFeature.FAN_MODE), + (SET_HUMIDITY_ACTION, ClimateEntityFeature.TARGET_HUMIDITY), + (SET_HVAC_MODE_ACTION, 0), + (SET_PRESET_MODE_ACTION, ClimateEntityFeature.PRESET_MODE), + ( + SET_SWING_HORIZONTAL_MODE_ACTION, + ClimateEntityFeature.SWING_HORIZONTAL_MODE, + ), + (SET_SWING_MODE_ACTION, ClimateEntityFeature.SWING_MODE), + (SET_TEMPERATURE_ACTION, ClimateEntityFeature.TARGET_TEMPERATURE), + ): + if (action_config := self._config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + if ( + ( + CONF_TARGET_TEMPERATURE_HIGH in self._templates + and CONF_TARGET_TEMPERATURE_LOW in self._templates + ) + or ( + CONF_TARGET_TEMPERATURE_HIGH in self._assumed_attributes + and CONF_TARGET_TEMPERATURE_LOW in self._assumed_attributes + ) + ) and SET_TEMPERATURE_ACTION in self._action_scripts: + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + + def _update_hvac_mode(self, render) -> None: + if render is None: + self._attr_hvac_mode = None + return + + self._attr_hvac_mode = HVACMode(render) + + def _update_hvac_modes(self, render) -> None: + + if isinstance(render, TemplateError): + self._attr_hvac_modes = [] + return + + if HVACMode.OFF in render: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + else: + self._attr_supported_features &= ~ClimateEntityFeature.TURN_OFF + + if any( + mode in render + for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) + ) or ( + len(render) == 2 + and HVACMode.OFF in render + and any(mode != HVACMode.OFF for mode in render) + ): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON + else: + self._attr_supported_features &= ~ClimateEntityFeature.TURN_ON + + self._attr_hvac_modes = render + + def _round_temperature_value(self, value: Any) -> float: + if self._attr_target_temperature_step is None: + return value + rounded = _round_to_step(float(value), self._attr_target_temperature_step) + return min(self.max_temp, max(self.min_temp, rounded)) + + def _update_target_temperature(self, attr: str) -> Callable[[Any], None]: + def update(result: Any) -> None: + if result is None: + setattr(self, attr, None) + return + + value = self._round_temperature_value(result) + setattr(self, attr, value) + + return update + + def _round_humidity_value(self, value: Any) -> int: + result = ( + value + if self._attr_target_humidity_step is None + else int( + _round_to_step( + float(value) / 10.0, self._attr_target_humidity_step / 10.0 + ) + * 10.0 + ) + ) + return int(min(self.max_humidity, max(self.min_humidity, result))) + + def _update_target_humidity( + self, + result, + ) -> None: + if result is None: + self._attr_target_humidity = None + return + + self._attr_target_humidity = self._round_humidity_value(result) + + async def _async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if script := self._action_scripts.get(SET_HVAC_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"hvac_mode": hvac_mode}, + context=self._context, + ) + + @override + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + await self._async_set_hvac_mode(hvac_mode) + + if self._attr_assumed_state: + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + @override + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + if script := self._action_scripts.get(SET_PRESET_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"preset_mode": preset_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_PRESET_MODE, preset_mode) + + @override + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + if script := self._action_scripts.get(SET_FAN_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"fan_mode": fan_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_FAN_MODE, fan_mode) + + @override + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing mode.""" + if script := self._action_scripts.get(SET_SWING_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"swing_mode": swing_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_SWING_MODE, swing_mode) + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the swing horizontal mode.""" + if script := self._action_scripts.get(SET_SWING_HORIZONTAL_MODE_ACTION): + await self.async_run_script( + script, + run_variables={"swing_horizontal_mode": swing_horizontal_mode}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_SWING_HORIZONTAL_MODE, swing_horizontal_mode) + + @override + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity.""" + rounded = self._round_humidity_value(humidity) + if script := self._action_scripts.get(SET_HUMIDITY_ACTION): + await self.async_run_script( + script, + run_variables={"humidity": rounded}, + context=self._context, + ) + + self.write_assumed_attribute(CONF_TARGET_HUMIDITY, rounded) + + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set one or more target temperatures.""" + common_params: dict[str, Any] = { + "temperature": None, + "target_temp_high": None, + "target_temp_low": None, + "hvac_mode": None, + } + write_state = False + + breadcrumb = f"{SET_TEMPERATURE_ACTION} {ATTR_HVAC_MODE}" + if (hvac_value := kwargs.get(ATTR_HVAC_MODE)) and ( + hvac_mode := tcv.strenum(self, breadcrumb, HVACMode)(hvac_value) + ) is not None: + self._valid_mode_or_raise("hvac", hvac_mode, self.hvac_modes) + common_params["hvac_mode"] = hvac_mode + await self._async_set_hvac_mode(HVACMode(hvac_mode)) + if self._attr_assumed_state: + self._attr_hvac_mode = hvac_mode + write_state = True + + updates = [] + for option, attr, param in ( + ( + CONF_TARGET_TEMPERATURE, + ATTR_TEMPERATURE, + "temperature", + ), + ( + CONF_TARGET_TEMPERATURE_HIGH, + ATTR_TARGET_TEMP_HIGH, + "target_temp_high", + ), + ( + CONF_TARGET_TEMPERATURE_LOW, + ATTR_TARGET_TEMP_LOW, + "target_temp_low", + ), + ): + if (value := kwargs.get(attr)) is not None and ( + validated := tcv.number( + self, + f"{SET_TEMPERATURE_ACTION} {attr}", + self.min_temp, + self.max_temp, + )(value) + ) is not None: + rounded = self._round_temperature_value(validated) + common_params[param] = rounded + updates.append((option, rounded)) + + if script := self._action_scripts.get(SET_TEMPERATURE_ACTION): + await self.async_run_script( + script, + run_variables=common_params, + context=self._context, + ) + + for option, value in updates: + if self.update_assumed_attribute(option, value): + write_state = True + + if write_state: + self.async_write_ha_state() + + @property + @override + def extra_restore_state_data(self) -> ClimateExtraStoredData: + """Return climate specific state data to be restored.""" + return ClimateExtraStoredData( + current_humidity=self._attr_current_humidity, + current_temperature=self._attr_current_temperature, + fan_mode=self._attr_fan_mode, + fan_modes=self._attr_fan_modes, + hvac_action=self._attr_hvac_action, + hvac_mode=self._attr_hvac_mode, + hvac_modes=self._attr_hvac_modes, + preset_mode=self._attr_preset_mode, + preset_modes=self._attr_preset_modes, + swing_mode=self._attr_swing_mode, + swing_modes=self._attr_swing_modes, + swing_horizontal_mode=self._attr_swing_horizontal_mode, + swing_horizontal_modes=self._attr_swing_horizontal_modes, + target_humidity=self._attr_target_humidity, + target_temperature_high=self._attr_target_temperature_high, + target_temperature_low=self._attr_target_temperature_low, + target_temperature=self._attr_target_temperature, + ) + + @override + def restore_extra_data(self, extra_data: ClimateExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_current_humidity = extra_data.current_humidity + self._attr_current_temperature = extra_data.current_temperature + self._attr_fan_mode = extra_data.fan_mode + self._attr_fan_modes = extra_data.fan_modes + self._attr_hvac_action = extra_data.hvac_action + self._attr_hvac_mode = extra_data.hvac_mode + self._update_hvac_modes(extra_data.hvac_modes or []) + self._attr_preset_mode = extra_data.preset_mode + self._attr_preset_modes = extra_data.preset_modes + self._attr_swing_mode = extra_data.swing_mode + self._attr_swing_modes = extra_data.swing_modes + self._attr_swing_horizontal_mode = extra_data.swing_horizontal_mode + self._attr_swing_horizontal_modes = extra_data.swing_horizontal_modes + self._attr_target_humidity = extra_data.target_humidity + self._attr_target_temperature_high = extra_data.target_temperature_high + self._attr_target_temperature_low = extra_data.target_temperature_low + self._attr_target_temperature = extra_data.target_temperature + + +class StateClimateEntity(TemplateEntity, AbstractTemplateClimate): + """Representation of a state-based template climate.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: + """Initialize the state-based template climate.""" + TemplateEntity.__init__(self, hass, config, unique_id) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + AbstractTemplateClimate.__init__(self, hass, name, config) + + +class TriggerClimateEntity(TriggerEntity, AbstractTemplateClimate): + """Representation of a trigger-based template climate.""" + + domain = CLIMATE_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the trigger-based template climate.""" + TriggerEntity.__init__(self, hass, coordinator, config) + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + AbstractTemplateClimate.__init__(self, hass, name, config) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 08d22e11a1a6..df6cb47c54d2 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -17,6 +17,7 @@ from homeassistant.components.blueprint import ( schemas as blueprint_schemas, ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.event import DOMAIN as EVENT_DOMAIN @@ -60,6 +61,7 @@ from . import ( alarm_control_panel as alarm_control_panel_platform, binary_sensor as binary_sensor_platform, button as button_platform, + climate as climate_platform, cover as cover_platform, device_tracker as device_tracker_platform, event as event_platform, @@ -89,6 +91,7 @@ _DEFAULT_NAMES = { Platform.BINARY_SENSOR: binary_sensor_platform.DEFAULT_NAME, Platform.BUTTON: button_platform.DEFAULT_NAME, Platform.COVER: cover_platform.DEFAULT_NAME, + Platform.CLIMATE: climate_platform.DEFAULT_NAME, Platform.DEVICE_TRACKER: device_tracker_platform.DEFAULT_NAME, Platform.EVENT: event_platform.DEFAULT_NAME, Platform.FAN: fan_platform.DEFAULT_NAME, @@ -250,6 +253,9 @@ CONFIG_SECTION_SCHEMA = probatio.All( probatio.Optional(BUTTON_DOMAIN): probatio.All( cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), + probatio.Optional(CLIMATE_DOMAIN): probatio.All( + cv.ensure_list, [climate_platform.CLIMATE_YAML_SCHEMA] + ), probatio.Optional(COVER_DOMAIN): probatio.All( cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index cbce57db40fd..84901194565b 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_URL, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + DEGREE, Platform, UnitOfTemperature, ) @@ -37,6 +38,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowMenuStep, ) +from . import validators as tcv from .alarm_control_panel import ( CONF_ARM_AWAY_ACTION, CONF_ARM_CUSTOM_BYPASS_ACTION, @@ -51,6 +53,18 @@ from .alarm_control_panel import ( async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor +from .climate import ( + CONF_CURRENT_TEMPERATURE, + CONF_HVAC_ACTION, + CONF_HVAC_MODE, + CONF_HVAC_MODES, + CONF_MAX_TEMPERATURE, + CONF_MIN_TEMPERATURE, + CONF_TARGET_TEMPERATURE, + SET_HVAC_MODE_ACTION, + SET_TEMPERATURE_ACTION, + async_create_preview_climate, +) from .const import ( CONF_ADDITIONAL_OPTIONS, CONF_AVAILABILITY, @@ -196,6 +210,40 @@ def generate_schema(domain: str, flow_type: str) -> probatio.Schema: ), } + if domain == Platform.CLIMATE: + schema |= { + probatio.Required(CONF_HVAC_MODES): selector.TemplateSelector(), + probatio.Optional(CONF_HVAC_MODE): selector.TemplateSelector(), + probatio.Required(SET_HVAC_MODE_ACTION): selector.ActionSelector(), + probatio.Optional(CONF_HVAC_ACTION): selector.TemplateSelector(), + probatio.Optional(CONF_CURRENT_TEMPERATURE): selector.TemplateSelector(), + probatio.Optional(CONF_TARGET_TEMPERATURE): selector.TemplateSelector(), + probatio.Optional(SET_TEMPERATURE_ACTION): selector.ActionSelector(), + probatio.Optional(CONF_TEMPERATURE_UNIT): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in UnitOfTemperature], + mode=selector.SelectSelectorMode.DROPDOWN, + sort=True, + ), + ), + } + additional_options |= { + probatio.Optional(CONF_MIN_TEMPERATURE): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=DEGREE, + step=0.1, + ) + ), + probatio.Optional(CONF_MAX_TEMPERATURE): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=DEGREE, + step=0.1, + ) + ), + } + if domain == Platform.COVER: schema |= _SCHEMA_STATE | { probatio.Inclusive( @@ -505,6 +553,10 @@ def validate_user_input( if template_type == Platform.SENSOR: _validate_unit(user_input) _validate_state_class(user_input) + if template_type == Platform.CLIMATE: + tcv.requires_option(CONF_TARGET_TEMPERATURE, SET_TEMPERATURE_ACTION)( + user_input + ) return {"template_type": template_type} | user_input return _validate_user_input @@ -514,6 +566,7 @@ TEMPLATE_TYPES = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, Platform.EVENT, @@ -546,6 +599,11 @@ CONFIG_FLOW = { config_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.CLIMATE: SchemaFlowFormStep( + config_schema(Platform.CLIMATE), + preview="template", + validate_user_input=validate_user_input(Platform.CLIMATE), + ), Platform.COVER: SchemaFlowFormStep( config_schema(Platform.COVER), preview="template", @@ -635,6 +693,11 @@ OPTIONS_FLOW = { options_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.CLIMATE: SchemaFlowFormStep( + options_schema(Platform.CLIMATE), + preview="template", + validate_user_input=validate_user_input(Platform.CLIMATE), + ), Platform.COVER: SchemaFlowFormStep( options_schema(Platform.COVER), preview="template", @@ -713,6 +776,7 @@ CREATE_PREVIEW_ENTITY: dict[ ] = { Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.CLIMATE: async_create_preview_climate, Platform.COVER: async_create_preview_cover, Platform.DEVICE_TRACKER: async_create_preview_tracker, Platform.EVENT: async_create_preview_event, diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 816b77b5284d..bf34f3b620bd 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -25,6 +25,7 @@ PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, Platform.EVENT, diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index eb50c80c5c2d..766edc199579 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -64,6 +64,7 @@ class AbstractTemplateEntity(Entity): self._templates: dict[str, EntityTemplate] = {} self._action_scripts: dict[str, Script] = {} self._attr_extra_state_attributes = {} + self._assumed_attributes: dict[str, str] = {} self._attribute_templates: dict[str, Template] | None = None self._attributes_template: Template | None = None @@ -201,6 +202,24 @@ class AbstractTemplateEntity(Entity): domain, ) + def add_assumed_attribute(self, attr: str, option: str, action_option: str): + """Add an optimistic option.""" + if option not in self._config and action_option in self._config: + self._assumed_attributes[option] = attr + + def update_assumed_attribute(self, option: str, value: Any) -> bool: + """If the attribute is assumed, update attribute with the new value.""" + attr = self._assumed_attributes.get(option) + if assumed_attribute := attr is not None: + setattr(self, attr, value) + + return assumed_attribute + + def write_assumed_attribute(self, option: str, value: Any) -> None: + """If the attribute is assumed, write the value to the attribute and update the ha state.""" + if self.update_assumed_attribute(option, value): + self.async_write_ha_state() + @override async def async_will_remove_from_hass(self) -> None: """Clean up scripts when removing from Home Assistant.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 570c91aabdb9..99bfe69c5301 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -7,6 +7,8 @@ "device_class": "Device class", "device_id_description": "Select a device to link to this entity.", "state": "State", + "temperature_unit": "Temperature unit", + "temperature_unit_description": "The unit for any temperature template output. One of `°C`, `°F`, or `K`.", "turn_off": "Actions on turn off", "turn_on": "Actions on turn on", "unit_of_measurement": "Unit of measurement" @@ -102,6 +104,46 @@ }, "title": "Template button" }, + "climate": { + "data": { + "current_temperature": "[%key:component::climate::entity_component::_::state_attributes::current_temperature::name%]", + "device_id": "[%key:common::config_flow::data::device%]", + "hvac_action": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::name%]", + "hvac_mode": "HVAC mode", + "hvac_modes": "[%key:component::climate::entity_component::_::state_attributes::hvac_modes::name%]", + "name": "[%key:common::config_flow::data::name%]", + "set_hvac_mode": "Actions on set HVAC mode", + "set_temperature": "Actions on set temperature", + "target_temperature": "[%key:component::climate::entity_component::_::state_attributes::temperature::name%]", + "temperature_unit": "[%key:component::template::common::temperature_unit%]" + }, + "data_description": { + "current_temperature": "Defines a template for the current temperature.", + "device_id": "[%key:component::template::common::device_id_description%]", + "hvac_action": "Defines a template for the current HVAC action. Valid HVAC actions are `cooling`, `defrosting`, `drying`, `fan`, `heating`, `idle`, `off`, and `preheating`.", + "hvac_mode": "Defines a template for the current HVAC mode. Valid HVAC modes are `auto`, `cool`, `dry`, `fan_only`, `heat`, `heat_cool` and `off`.", + "hvac_modes": "Defines a template for a list of available HVAC modes. Valid HVAC modes for the list are `auto`, `cool`, `dry`, `fan_only`, `heat`, `heat_cool` and `off`.", + "set_hvac_mode": "Defines actions to run when the climate is given a `set_hvac_mode` command. Receives variable `hvac_mode`.", + "set_temperature": "Defines actions to run when the climate is given a `set_temperature` command. Receives variable `hvac_mode`, `temperature`, `target_temp_high`, and `target_temp_low`. If `hvac_mode` is received, the `set_hvac_mode` actions are executed before the actions in `set_temperature`.", + "target_temperature": "Defines a template for the target temperature.", + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" + }, + + "sections": { + "additional_options": { + "data": { + "availability": "[%key:component::template::common::availability%]", + "max_temperature": "[%key:component::climate::entity_component::_::state_attributes::max_temp::name%]", + "min_temperature": "[%key:component::climate::entity_component::_::state_attributes::min_temp::name%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + }, + "name": "[%key:component::template::common::additional_options%]" + } + }, + "title": "Template Thermostat" + }, "cover": { "data": { "close_cover": "Actions on close", @@ -483,6 +525,7 @@ "alarm_control_panel": "[%key:component::alarm_control_panel::title%]", "binary_sensor": "[%key:component::binary_sensor::title%]", "button": "[%key:component::button::title%]", + "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", "device_tracker": "[%key:component::device_tracker::title%]", "event": "[%key:component::event::title%]", @@ -550,7 +593,7 @@ "humidity": "Humidity", "name": "[%key:common::config_flow::data::name%]", "temperature": "Temperature", - "temperature_unit": "Temperature unit" + "temperature_unit": "[%key:component::template::common::temperature_unit%]" }, "data_description": { "condition": "Defines a template to get the current weather condition", @@ -559,7 +602,7 @@ "forecast_hourly": "Defines a template to get the [hourly forecast data]({hourly_link})", "humidity": "Defines a template to get the current humidity", "temperature": "Defines a template to get the current temperature", - "temperature_unit": "The temperature unit" + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" }, "sections": { "additional_options": { @@ -701,6 +744,46 @@ }, "title": "[%key:component::template::config::step::button::title%]" }, + "climate": { + "data": { + "current_temperature": "[%key:component::climate::entity_component::_::state_attributes::current_temperature::name%]", + "device_id": "[%key:common::config_flow::data::device%]", + "hvac_action": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::name%]", + "hvac_mode": "[%key:component::template::config::step::climate::data::hvac_mode%]", + "hvac_modes": "[%key:component::climate::entity_component::_::state_attributes::hvac_modes::name%]", + "name": "[%key:common::config_flow::data::name%]", + "set_hvac_mode": "[%key:component::template::config::step::climate::data::set_hvac_mode%]", + "set_temperature": "[%key:component::template::config::step::climate::data::set_temperature%]", + "target_temperature": "[%key:component::climate::entity_component::_::state_attributes::temperature::name%]", + "temperature_unit": "[%key:component::template::common::temperature_unit%]" + }, + "data_description": { + "current_temperature": "[%key:component::template::config::step::climate::data_description::current_temperature%]", + "device_id": "[%key:component::template::common::device_id_description%]", + "hvac_action": "[%key:component::template::config::step::climate::data_description::hvac_action%]", + "hvac_mode": "[%key:component::template::config::step::climate::data_description::hvac_mode%]", + "hvac_modes": "[%key:component::template::config::step::climate::data_description::hvac_modes%]", + "set_hvac_mode": "[%key:component::template::config::step::climate::data_description::set_hvac_mode%]", + "set_temperature": "[%key:component::template::config::step::climate::data_description::set_temperature%]", + "target_temperature": "[%key:component::template::config::step::climate::data_description::target_temperature%]", + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" + }, + + "sections": { + "additional_options": { + "data": { + "availability": "[%key:component::template::common::availability%]", + "max_temperature": "[%key:component::climate::entity_component::_::state_attributes::max_temp::name%]", + "min_temperature": "[%key:component::climate::entity_component::_::state_attributes::min_temp::name%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + }, + "name": "[%key:component::template::common::additional_options%]" + } + }, + "title": "[%key:component::template::config::step::climate::title%]" + }, "cover": { "data": { "close_cover": "[%key:component::template::config::step::cover::data::close_cover%]", @@ -1115,7 +1198,7 @@ "humidity": "[%key:component::template::config::step::weather::data::humidity%]", "name": "[%key:common::config_flow::data::name%]", "temperature": "[%key:component::template::config::step::weather::data::temperature%]", - "temperature_unit": "[%key:component::template::config::step::weather::data::temperature_unit%]" + "temperature_unit": "[%key:component::template::common::temperature_unit%]" }, "data_description": { "condition": "[%key:component::template::config::step::weather::data_description::condition%]", @@ -1124,7 +1207,7 @@ "forecast_hourly": "[%key:component::template::config::step::weather::data_description::forecast_hourly%]", "humidity": "[%key:component::template::config::step::weather::data_description::humidity%]", "temperature": "[%key:component::template::config::step::weather::data_description::temperature%]", - "temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]" + "temperature_unit": "[%key:component::template::common::temperature_unit_description%]" }, "sections": { "additional_options": { diff --git a/homeassistant/components/template/validators.py b/homeassistant/components/template/validators.py index 82147be7d4ee..7f946e54abb3 100644 --- a/homeassistant/components/template/validators.py +++ b/homeassistant/components/template/validators.py @@ -463,3 +463,42 @@ def check_conditions( ) return condition_result + + +def inclusive_group(name: str, optional: str, *required: str) -> Callable[[dict], dict]: + """Validate an inclusive group of configuration options, with 1 optional option. + + The optional member requires all required options, however the required options + do not require the optional option. + """ + _all = {optional, *required} + _required = set(required) + + def verify(obj: dict) -> dict: + options = set(obj.keys()) + if not (common := options.intersection(_all)) or common in (_required, _all): + return obj + + missing = _required - common + raise probatio.Invalid( + f"Some required option(s) are missing from inclusive group '{name}', expected missing options: {', '.join(missing)}." + ) + + return verify + + +def requires_option(option: str, required_option: str) -> Callable[[dict], dict]: + """Validate a pair of options. + + Raises probatio.Invalid if required_option is missing when option is present. + """ + + def verify(obj: dict) -> dict: + if (option in obj and required_option in obj) or option not in obj: + return obj + + raise probatio.Invalid( + f"Required option: '{required_option}' is missing for option '{option}'. Remove '{option}' from your config or add '{required_option}'." + ) + + return verify diff --git a/tests/components/template/snapshots/test_climate.ambr b/tests/components/template/snapshots/test_climate.ambr new file mode 100644 index 000000000000..089d3796e9f2 --- /dev/null +++ b/tests/components/template/snapshots/test_climate.ambr @@ -0,0 +1,24 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'My template', + : list([ + , + , + , + , + ]), + : 35, + : 7, + : , + }), + 'context': , + 'entity_id': 'climate.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/template/test_climate.py b/tests/components/template/test_climate.py new file mode 100644 index 000000000000..45c4e3f97c62 --- /dev/null +++ b/tests/components/template/test_climate.py @@ -0,0 +1,2451 @@ +"""The tests for the Template climate platform.""" + +from enum import StrEnum +from itertools import chain +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import climate, template +from homeassistant.components.climate import ( + ClimateEntityCapabilityAttribute, + ClimateEntityFeature, + ClimateEntityStateAttribute, + HVACAction, + HVACMode, +) +from homeassistant.components.template.climate import DEFAULT_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + assert_attributes_template, + assert_extra_template_attributes, + assert_invalid_config_entry_actions_do_not_create_entities, + assert_invalid_yaml_actions_do_not_create_entities, + assert_state_and_attributes, + async_get_flow_preview_state, + async_trigger, + make_test_action, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, +) + +from tests.common import MockConfigEntry, async_mock_restore_state_shutdown_restart +from tests.typing import WebSocketGenerator + +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" + +TEST_CLIMATE = TemplatePlatformSetup( + climate.DOMAIN, + "test_climate", + make_test_trigger( + TEST_STATE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY, + TEST_ATTRIBUTE_ENTITY_ID, + ), +) + +SET_FAN_MODE_ACTION = make_test_action( + "set_fan_mode", + { + "fan_mode": "{{ fan_mode }}", + }, +) +SET_HUMIDITY_ACTION = make_test_action( + "set_humidity", + { + "humidity": "{{ humidity }}", + }, +) +SET_HVAC_MODE_ACTION = make_test_action( + "set_hvac_mode", + { + "hvac_mode": "{{ hvac_mode }}", + }, +) +SET_PRESET_MODE_ACTION = make_test_action( + "set_preset_mode", + { + "preset_mode": "{{ preset_mode }}", + }, +) +SET_SWING_HORIZONTAL_MODE_ACTION = make_test_action( + "set_swing_horizontal_mode", + { + "swing_horizontal_mode": "{{ swing_horizontal_mode }}", + }, +) +SET_SWING_MODE_ACTION = make_test_action( + "set_swing_mode", + { + "swing_mode": "{{ swing_mode }}", + }, +) +SET_TEMPERATURE_ACTION = make_test_action( + "set_temperature", + { + "temperature": "{{ temperature }}", + "target_temp_high": "{{ target_temp_high }}", + "target_temp_low": "{{ target_temp_low }}", + "hvac_mode": "{{ hvac_mode }}", + }, +) + +HVAC_MODES = {"hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}"} +EXPECTED_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] +MINIMUM_REQUIREMENTS = { + **HVAC_MODES, + **SET_HVAC_MODE_ACTION, +} + + +async def _call_and_assert_action( + hass: HomeAssistant, + calls: list[ServiceCall], + service: str, + service_data: ConfigType | None = None, + expected_data: ConfigType | None = None, + expected_action: str | None = None, +) -> None: + """Call a service and validate that it was called properly. + + The service is validated when expected_action is omitted. + """ + if expected_action is None: + expected_action = service + current = len(calls) + await hass.services.async_call( + climate.DOMAIN, + service, + {**(service_data or {}), ATTR_ENTITY_ID: TEST_CLIMATE.entity_id}, + blocking=True, + ) + assert_action( + TEST_CLIMATE, calls, current + 1, expected_action, **(expected_data or {}) + ) + + +@pytest.fixture +async def setup_base_climate( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: dict[str, Any], +) -> None: + """Do setup of climate integration.""" + await setup_entity(hass, TEST_CLIMATE, style, count, config) + + +@pytest.fixture +async def setup_climate( + hass: HomeAssistant, + style: ConfigurationStyle, + config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of climate integration.""" + await setup_entity(hass, TEST_CLIMATE, style, 1, config, extra_config=extra_config) + + +@pytest.fixture +async def setup_single_attribute_climate( + hass: HomeAssistant, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of climate integration.""" + await setup_entity( + hass, + TEST_CLIMATE, + style, + 1, + {attribute: attribute_template} if attribute and attribute_template else {}, + extra_config=extra_config, + ) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("current_humidity", MINIMUM_REQUIREMENTS)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 20 }}", 20), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45), + ("{{ -1 }}", None), + ("{{ 101 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_humidity_template(hass: HomeAssistant, expected: Any) -> None: + """Test template humidity.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("current_humidity") == expected + + +@pytest.mark.parametrize( + ("config", "extra_config"), + [(MINIMUM_REQUIREMENTS, {"target_humidity": "{{ 65 }}", **SET_HUMIDITY_ACTION})], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_humidity_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test set_humidity action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_humidity", + {"humidity": 45}, + {"humidity": 45}, + "set_humidity", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes["humidity"] == 65 + + +@pytest.mark.parametrize( + ("config", "extra_config"), [(MINIMUM_REQUIREMENTS, SET_HUMIDITY_ACTION)] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_humidity_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test optimistic set_humidity action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_humidity", + {"humidity": 45}, + {"humidity": 45}, + "set_humidity", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes["humidity"] == 45 + + await _call_and_assert_action( + hass, + calls, + "set_humidity", + {"humidity": 65}, + {"humidity": 65}, + "set_humidity", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes["humidity"] == 65 + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("current_temperature", MINIMUM_REQUIREMENTS)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_temperature_template(hass: HomeAssistant, expected: Any) -> None: + """Test template temperature.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("current_temperature") == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("hvac_action", MINIMUM_REQUIREMENTS)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'cooling' }}", HVACAction.COOLING), + ("{{ 'defrosting' }}", HVACAction.DEFROSTING), + ("{{ 'drying' }}", HVACAction.DRYING), + ("{{ 'fan' }}", HVACAction.FAN), + ("{{ 'heating' }}", HVACAction.HEATING), + ("{{ 'idle' }}", HVACAction.IDLE), + ("{{ 'off' }}", HVACAction.OFF), + ("{{ 'preheating' }}", HVACAction.PREHEATING), + ("{{ 100 }}", None), + ("{{ 45.5 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_action_template(hass: HomeAssistant, expected: Any) -> None: + """Test template hvac_action.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("hvac_action") == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_humidity", + { + "min_humidity": 19, + "max_humidity": 100, + **SET_HUMIDITY_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 20 }}", 20), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45), + ("{{ -1 }}", None), + ("{{ 101 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_humidity_template(hass: HomeAssistant, expected: Any) -> None: + """Test template target_humidity.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("humidity") == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", + [ + {"target_humidity": 32, **MINIMUM_REQUIREMENTS}, + ], +) +async def test_missing_set_humidity_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad target humidity configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': Required option: 'set_humidity' is missing for option" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_temperature", + { + "min_temperature": -2, + "max_temperature": 101, + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ -3 }}", None), + ("{{ 103 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_temperature_template(hass: HomeAssistant, expected: Any) -> None: + """Test template target_temperature.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("temperature") == expected + + +@pytest.mark.parametrize( + "config", + [{"hvac_mode": "{{ 'cool' }}", **MINIMUM_REQUIREMENTS, **SET_TEMPERATURE_ACTION}], +) +@pytest.mark.parametrize( + ( + "extra_config", + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + { + "target_temperature": "{{ 21 }}", + }, + {"temperature": 18}, + {"temperature": 18}, + {"temperature": 21}, + ), + ( + { + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 10, "target_temp_high": 30}, + ), + ( + { + "target_temperature": "{{ 20 }}", + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_temperature_action_with_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test set_temperature action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await hass.services.async_call( + climate.DOMAIN, + "set_temperature", + { + "hvac_mode": "heat", + **action_variables, + ATTR_ENTITY_ID: TEST_CLIMATE.entity_id, + }, + blocking=True, + ) + assert_action(TEST_CLIMATE, calls, 2, "set_hvac_mode", 0, hvac_mode="heat") + assert_action( + TEST_CLIMATE, + calls, + 2, + "set_temperature", + hvac_mode="heat", + **expected_variables, + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, HVACMode.COOL, expected_attributes) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + climate.DOMAIN, + "set_temperature", + { + "hvac_mode": "fan_only", + "temperature": 20, + ATTR_ENTITY_ID: TEST_CLIMATE.entity_id, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("config", [{**MINIMUM_REQUIREMENTS, **SET_TEMPERATURE_ACTION}]) +@pytest.mark.parametrize( + ( + "extra_config", + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + {"target_temperature": "{{ 21 }}"}, + {"temperature": 18}, + {"temperature": 18}, + {"temperature": 21}, + ), + ( + { + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 10, "target_temp_high": 30}, + ), + ( + { + "target_temperature": "{{ 20 }}", + "target_temperature_low": "{{ 10 }}", + "target_temperature_high": "{{ 30 }}", + }, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 21, "target_temp_low": 11, "target_temp_high": 29}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_temperature_action_without_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test set_temperature action does not call set_hvac_mode action without hvac_mode.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_temperature", + action_variables, + expected_variables, + "set_temperature", + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, STATE_UNKNOWN, expected_attributes) + + +@pytest.mark.parametrize( + ("config", "extra_config"), [(MINIMUM_REQUIREMENTS, SET_TEMPERATURE_ACTION)] +) +@pytest.mark.parametrize( + ( + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + {"temperature": 22}, + {"temperature": 22}, + {"temperature": 22}, + ), + ( + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + ), + ( + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_temperature_action_with_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test optimistic set_temperature action with hvac_mode.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + assert_state_and_attributes( + hass, + TEST_CLIMATE, + STATE_UNKNOWN, + {"temperature": None, "target_temp_low": None, "target_temp_high": None}, + ) + + await hass.services.async_call( + climate.DOMAIN, + "set_temperature", + { + "hvac_mode": "cool", + **action_variables, + ATTR_ENTITY_ID: TEST_CLIMATE.entity_id, + }, + blocking=True, + ) + assert_action(TEST_CLIMATE, calls, 2, "set_hvac_mode", 0, hvac_mode="cool") + assert_action( + TEST_CLIMATE, + calls, + 2, + "set_temperature", + hvac_mode="cool", + **expected_variables, + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, HVACMode.COOL, expected_attributes) + + +@pytest.mark.parametrize( + ("config", "extra_config"), [(MINIMUM_REQUIREMENTS, SET_TEMPERATURE_ACTION)] +) +@pytest.mark.parametrize( + ( + "action_variables", + "expected_variables", + "expected_attributes", + ), + [ + ( + {"temperature": 21}, + {"temperature": 21}, + {"temperature": 21}, + ), + ( + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + {"target_temp_low": 11, "target_temp_high": 29}, + ), + ( + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + {"temperature": 20, "target_temp_low": 10, "target_temp_high": 30}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_temperature_action_without_hvac_mode( + hass: HomeAssistant, + action_variables: ConfigType, + expected_variables: ConfigType, + expected_attributes: ConfigType, + calls: list[ServiceCall], +) -> None: + """Test optimistic set_temperature action.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + assert_state_and_attributes( + hass, + TEST_CLIMATE, + STATE_UNKNOWN, + {"temperature": None, "target_temp_low": None, "target_temp_high": None}, + ) + + await _call_and_assert_action( + hass, + calls, + "set_temperature", + action_variables, + expected_variables, + "set_temperature", + ) + assert_state_and_attributes(hass, TEST_CLIMATE, STATE_UNKNOWN, expected_attributes) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_temperature_high", + { + "min_temperature": -3, + "max_temperature": 101, + "target_temperature_low": "{{ -3 }}", + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ -4 }}", None), + ("{{ 103 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_temperature_high_template( + hass: HomeAssistant, expected: Any +) -> None: + """Test template target_temperature_high.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("target_temp_high") == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "target_temperature_low", + { + "min_temperature": -2, + "max_temperature": 102, + "target_temperature_high": "{{ 102 }}", + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ -1 }}", -1), + ("{{ 5.3423 }}", 5.3), + ("{{ 30 }}", 30), + ("{{ 45 }}", 45), + ("{{ 99 }}", 99), + ("{{ 100 }}", 100), + ("{{ 45.5 }}", 45.5), + ("{{ -3 }}", None), + ("{{ 103 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_target_temperature_low_template( + hass: HomeAssistant, expected: Any +) -> None: + """Test template target_temperature_low.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("target_temp_low") == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", + [ + {"target_temperature_high": 30, **MINIMUM_REQUIREMENTS}, + {"target_temperature_low": 30, **MINIMUM_REQUIREMENTS}, + ], +) +async def test_bad_target_temperature_range_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad target temperature range configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': some but not all values in the same group of inclusion 'temperature_limits'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "config", + [ + { + "target_temperature_high": 32, + "target_temperature_low": 17, + **MINIMUM_REQUIREMENTS, + }, + {"target_temperature": 21, **MINIMUM_REQUIREMENTS}, + ], +) +async def test_missing_set_temperature_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad target temperature range configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': Required option: 'set_temperature' is missing for option" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [("hvac_modes", SET_HVAC_MODE_ACTION)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ( + "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL], + ), + ( + "{{ ['dry', 'auto', 'fan_only'] }}", + [HVACMode.DRY, HVACMode.AUTO, HVACMode.FAN_ONLY], + ), + ("{{ [] }}", []), + ("{{ '[]' }}", []), + ( + "{{ ['dry', 'auto2', 'fan_only'] }}", + [HVACMode.DRY, HVACMode.FAN_ONLY], + ), + ("{{ -3 }}", []), + ("{{ 103.3 }}", []), + ("{{ True }}", []), + ("{{ False }}", []), + ("{{ 'something' }}", []), + ("{{ x - 1 }}", []), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_modes_template( + hass: HomeAssistant, attribute: str, expected: Any +) -> None: + """Test hvac_modes template.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "hvac_mode", + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool', 'dry', 'auto'] }}", + **SET_HVAC_MODE_ACTION, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'off' }}", HVACMode.OFF), + ("{{ 'heat' }}", HVACMode.HEAT), + ("{{ 'cool' }}", HVACMode.COOL), + ("{{ 'heat_cool' }}", HVACMode.HEAT_COOL), + ("{{ 'dry' }}", HVACMode.DRY), + ("{{ 'auto' }}", HVACMode.AUTO), + ("{{ 'fan_only' }}", STATE_UNKNOWN), + ("{{ -3 }}", STATE_UNKNOWN), + ("{{ 103.3 }}", STATE_UNKNOWN), + ("{{ True }}", STATE_UNKNOWN), + ("{{ False }}", STATE_UNKNOWN), + ("{{ 'something' }}", STATE_UNKNOWN), + ("{{ x - 1 }}", STATE_UNAVAILABLE), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_mode_template(hass: HomeAssistant, expected: Any) -> None: + """Test hvac_mode template.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("config", "option"), + [ + ( + SET_HVAC_MODE_ACTION, + "hvac_modes", + ), + ( + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool', 'dry', 'auto', 'fan_only'] }}", + }, + "set_hvac_mode", + ), + ], +) +async def test_required_hvac_mode_options( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + option: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test missing required options.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': required key '{option}' not provided" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("attribute", "attribute_template", "extra_config"), + [ + ( + "hvac_modes", + "{{ state_attr('sensor.test_attribute', 'hvac_modes') or [] }}", + SET_HVAC_MODE_ACTION, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_hvac_modes_updates_supported_features(hass: HomeAssistant) -> None: + """Test hvac_modes updates supported features.""" + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [] + assert state.attributes["supported_features"] == 0 + + await async_trigger( + hass, + TEST_ATTRIBUTE_ENTITY_ID, + "anything", + {"hvac_modes": ["heat"]}, + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [HVACMode.HEAT] + assert state.attributes["supported_features"] == ClimateEntityFeature.TURN_ON + + await async_trigger( + hass, + TEST_ATTRIBUTE_ENTITY_ID, + "anything", + {"hvac_modes": ["off", "heat"]}, + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [HVACMode.OFF, HVACMode.HEAT] + assert ( + state.attributes["supported_features"] + == ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + + await async_trigger( + hass, + TEST_ATTRIBUTE_ENTITY_ID, + "anything", + {"hvac_modes": ["cool"]}, + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.state == STATE_UNKNOWN + assert state.attributes["hvac_modes"] == [HVACMode.COOL] + assert state.attributes["supported_features"] == ClimateEntityFeature.TURN_ON + + +@pytest.mark.parametrize(("config", "extra_config"), [(MINIMUM_REQUIREMENTS, {})]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_set_hvac_mode_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_hvac_mode", + {"hvac_mode": "heat"}, + {"hvac_mode": "heat"}, + "set_hvac_mode", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + climate.DOMAIN, + "set_hvac_mode", + {"hvac_mode": "fan_only", ATTR_ENTITY_ID: TEST_CLIMATE.entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize(("config", "extra_config"), [(MINIMUM_REQUIREMENTS, {})]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_climate") +async def test_optimistic_set_hvac_mode_action( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, + calls, + "set_hvac_mode", + {"hvac_mode": "heat"}, + {"hvac_mode": "heat"}, + "set_hvac_mode", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.state == HVACMode.HEAT + + await _call_and_assert_action( + hass, + calls, + "set_hvac_mode", + {"hvac_mode": "cool"}, + {"hvac_mode": "cool"}, + "set_hvac_mode", + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.state == HVACMode.COOL + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ("fan_modes", {**SET_FAN_MODE_ACTION, **MINIMUM_REQUIREMENTS}), + ("swing_modes", {**SET_SWING_MODE_ACTION, **MINIMUM_REQUIREMENTS}), + ( + "swing_horizontal_modes", + {**SET_SWING_HORIZONTAL_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + ), + ( + "preset_modes", + {**SET_PRESET_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ ['off', 'low', 'medium', 'high'] }}", ["off", "low", "medium", "high"]), + ("{{ ['off', 'high'] }}", ["off", "high"]), + ("{{ [] }}", []), + ("{{ '[]' }}", []), + ("{{ -3 }}", None), + ("{{ 103.3 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_group_modes_template( + hass: HomeAssistant, attribute: str, expected: Any +) -> None: + """Test template modes for inclusive group.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + ("attribute", "extra_config"), + [ + ( + "fan_mode", + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_FAN_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "swing_mode", + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_HORIZONTAL_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "preset_mode", + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_PRESET_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'off' }}", "off"), + ("{{ 'low' }}", "low"), + ("{{ 'medium' }}", "medium"), + ("{{ 'high' }}", "high"), + ("{{ -3 }}", None), + ("{{ 103.3 }}", None), + ("{{ True }}", None), + ("{{ False }}", None), + ("{{ 'something' }}", None), + ("{{ x - 1 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_group_mode_template( + hass: HomeAssistant, attribute: str, expected: Any +) -> None: + """Test template mode for inclusive group.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("config", "group"), + [ + ( + {**SET_FAN_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "fan_mode", + ), + ( + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "fan_mode", + ), + ( + {**SET_SWING_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "swing_mode", + ), + ( + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "swing_mode", + ), + ( + {**SET_SWING_HORIZONTAL_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "swing_horizontal_mode", + ), + ( + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "swing_horizontal_mode", + ), + ( + {**SET_PRESET_MODE_ACTION, **MINIMUM_REQUIREMENTS}, + "preset_mode", + ), + ( + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **MINIMUM_REQUIREMENTS, + }, + "preset_mode", + ), + ], +) +async def test_bad_mode_group_config( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + group: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad mode group configuration.""" + platform = TEST_CLIMATE + await setup_entity(hass, platform, style, 0, config) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': Some required option(s) are missing from inclusive group '{group}', expected missing options" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("action", "attribute", "extra_config"), + [ + ( + "set_fan_mode", + "fan_mode", + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_FAN_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_mode", + "swing_mode", + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_horizontal_mode", + "swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_HORIZONTAL_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_preset_mode", + "preset_mode", + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_PRESET_MODE_ACTION, + **MINIMUM_REQUIREMENTS, + }, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute_template", "mode", "expected"), + [ + ("{{ 'off' }}", "off", "off"), + ("{{ 'low' }}", "low", "low"), + ("{{ 'medium' }}", "medium", "medium"), + ("{{ 'high' }}", "high", "high"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_set_group_actions( + hass: HomeAssistant, + action: str, + attribute: str, + mode: str, + expected: Any, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + + await _call_and_assert_action( + hass, calls, action, {attribute: mode}, {attribute: mode}, action + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes.get(attribute) == expected + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + climate.DOMAIN, + action, + {attribute: "turbo", ATTR_ENTITY_ID: TEST_CLIMATE.entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("action", "attribute", "config"), + [ + ( + "set_fan_mode", + "fan_mode", + { + "fan_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_FAN_MODE_ACTION, + }, + ), + ( + "set_swing_mode", + "swing_mode", + { + "swing_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_MODE_ACTION, + }, + ), + ( + "set_swing_horizontal_mode", + "swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_SWING_HORIZONTAL_MODE_ACTION, + }, + ), + ( + "set_preset_mode", + "preset_mode", + { + "preset_modes": "{{ ['off', 'low', 'medium', 'high'] }}", + **SET_PRESET_MODE_ACTION, + }, + ), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "extra_config", + [MINIMUM_REQUIREMENTS], +) +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("off", "off"), + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ], +) +@pytest.mark.usefixtures("setup_climate") +async def test_group_optimistic_actions( + hass: HomeAssistant, + action: str, + attribute: str, + mode: str, + expected: Any, + calls: list[ServiceCall], +) -> None: + """Test setting valid group mode actions with template.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + await _call_and_assert_action( + hass, calls, action, {attribute: mode}, {attribute: mode}, action + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state is not None + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("option", "option_type"), + [ + ("max_humidity", "int"), + ("min_humidity", "int"), + ("max_temperature", "float"), + ("min_temperature", "float"), + ], +) +@pytest.mark.parametrize("value", ["not a number", None]) +async def test_bad_min_max_options( + hass: HomeAssistant, + style: ConfigurationStyle, + option: str, + option_type: str, + value: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad min max options in configuration.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {option: value, **MINIMUM_REQUIREMENTS} + ) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': expected {option_type} for dictionary value 'climate->0->{option}'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "config", + [ + { + "target_temperature": "{{ state_attr('sensor.test_attribute', 'value') or 0.0 }}", + **SET_TEMPERATURE_ACTION, + **MINIMUM_REQUIREMENTS, + } + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "set_attribute", "expected"), + [ + ({"target_temperature_step": 0.1}, 7.54, 7.5), + ({"target_temperature_step": 1.0}, 7.54, 8), + ({"target_temperature_step": 5.0}, 7.54, 10), + ], +) +@pytest.mark.usefixtures("setup_climate") +async def test_target_temperature_step( + hass: HomeAssistant, set_attribute: float, expected: float +) -> None: + """Test target temperature step.""" + await async_trigger( + hass, TEST_ATTRIBUTE_ENTITY_ID, "anything", {"value": set_attribute} + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("temperature") == expected + + +@pytest.mark.parametrize( + "config", + [ + { + "target_humidity": "{{ state_attr('sensor.test_attribute', 'value') or 0.0 }}", + **SET_HUMIDITY_ACTION, + **MINIMUM_REQUIREMENTS, + } + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("extra_config", "set_attribute", "expected"), + [ + ({"target_humidity_step": 1}, 44, 44), + ({"target_humidity_step": 5}, 44, 45), + ({"target_humidity_step": 7}, 44, 42), + ({"target_humidity_step": 10}, 44, 40), + ({"target_humidity_step": 15}, 44, 45), + ], +) +@pytest.mark.usefixtures("setup_climate") +async def test_target_humidity_step( + hass: HomeAssistant, set_attribute: int, expected: int +) -> None: + """Test target humidity step.""" + await async_trigger( + hass, TEST_ATTRIBUTE_ENTITY_ID, "anything", {"value": set_attribute} + ) + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("humidity") == expected + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("option", "option_type", "minimum"), + [ + ("target_humidity_step", "int", 1), + ("target_temperature_step", "float", 0.1), + ], +) +@pytest.mark.parametrize("value", [-1, 0, "not a number", None]) +async def test_bad_step_options( + hass: HomeAssistant, + style: ConfigurationStyle, + option: str, + option_type: str, + value: Any, + minimum: float, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad step options in configuration.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {option: value, **MINIMUM_REQUIREMENTS} + ) + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + f"Invalid config for 'template': expected {option_type} for dictionary value 'climate->0->{option}'" + in caplog.text + ) or ( + f"Invalid config for 'template': value must be at least {minimum} for dictionary value 'climate->0->{option}'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("value", "expected"), + [(0.5, 1.5), (0.1, 1.4), (1, 1)], +) +async def test_precision_option( + hass: HomeAssistant, style: ConfigurationStyle, value: float, expected: float +) -> None: + """Test precision option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, + platform, + style, + 1, + { + "precision": value, + "current_temperature": "{{ 1.4 }}", + **MINIMUM_REQUIREMENTS, + }, + ) + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_CLIMATE.entity_id) + assert state.attributes.get("current_temperature") == expected + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize("value", [-1, 0.0, "not a number", False, None]) +async def test_bad_precision_option( + hass: HomeAssistant, + style: ConfigurationStyle, + value: Any, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a bad precision option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {"precision": value, **MINIMUM_REQUIREMENTS} + ) + + assert len(hass.states.async_all(platform.domain)) == 0 + assert ( + "Invalid config for 'template': expected 0.5 or 0.1 or 1 for dictionary value 'climate->0->precision'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "value", + [UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN], +) +async def test_temperature_unit( + hass: HomeAssistant, style: ConfigurationStyle, value: str +) -> None: + """Test temperature_unit option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, + platform, + style, + 1, + { + "temperature_unit": value, + **MINIMUM_REQUIREMENTS, + }, + ) + + assert len(hass.states.async_all(platform.domain)) == 1 + assert hass.states.get(TEST_CLIMATE.entity_id) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize("value", [-1, 0.0, "not a number", False, None]) +async def test_bad_temperature_unit( + hass: HomeAssistant, + style: ConfigurationStyle, + value: Any, +) -> None: + """Test a bad temperature_unit option.""" + platform = TEST_CLIMATE + await setup_entity( + hass, platform, style, 0, {"temperature_unit": value, **MINIMUM_REQUIREMENTS} + ) + + assert len(hass.states.async_all(platform.domain)) == 0 + + +@pytest.mark.parametrize( + ("extra_config", "attribute_template"), + [ + ( + {"hvac_mode": "{{ states('sensor.test_state') }}", **MINIMUM_REQUIREMENTS}, + "{{ is_state('binary_sensor.availability', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_ON) + await hass.async_block_till_done() + + await async_trigger(hass, TEST_STATE_ENTITY_ID, HVACMode.HEAT) + + assert hass.states.get(TEST_CLIMATE.entity_id).state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_OFF) + await hass.async_block_till_done() + + await async_trigger(hass, TEST_STATE_ENTITY_ID, HVACMode.COOL) + + assert hass.states.get(TEST_CLIMATE.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("extra_config", "attribute_template"), + [ + ( + {"hvac_mode": "{{ states('sensor.test_state') }}", **MINIMUM_REQUIREMENTS}, + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_climate") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, caplog_setup_text: str +) -> None: + """Test that an invalid availability keeps the device available.""" + assert hass.states.get(TEST_CLIMATE.entity_id).state != STATE_UNAVAILABLE + assert "UndefinedError: 'x' is undefined" in caplog_setup_text + + +@pytest.mark.parametrize("config", [MINIMUM_REQUIREMENTS]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: + """Test unique_id option only creates one entity per id.""" + await setup_and_test_unique_id(hass, TEST_CLIMATE, style, config) + + +@pytest.mark.parametrize("config", [MINIMUM_REQUIREMENTS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_nested_unique_id( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, +) -> None: + """Test a template unique_id propagates to entity unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_CLIMATE, style, entity_registry, config + ) + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a entity from a config entry.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "hvac_mode": "{{ 'heat' }}", + **MINIMUM_REQUIREMENTS, + "template_type": climate.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + climate.DOMAIN, + {"name": "My template", "hvac_mode": "{{ 'heat' }}", **MINIMUM_REQUIREMENTS}, + ) + + assert state["state"] == HVACMode.HEAT + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": HVACAction.IDLE, + "hvac_modes": [HVACMode.COOL], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "humidity": None, + "target_temp_high": None, + "target_temp_low": None, + "temperature": None, + }, + ), + ( + # Missing key + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad hvac mode + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "not_cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad supported hvac modes + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["not_cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad hvac action + HVACMode.COOL, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idlex", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + STATE_UNAVAILABLE, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": "cool", + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + STATE_UNKNOWN, + { + "current_humidity": None, + "current_temperature": 35.0, + "fan_mode": None, + "fan_modes": None, + "hvac_action": "idle", + "hvac_mode": None, + "hvac_modes": ["cool"], + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + }, + STATE_UNKNOWN, + {}, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template climate.""" + + restored_attributes = { # These should be ignored + "current_position": 5, + "current_tilt_position": 5, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_CLIMATE, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_CLIMATE, + style, + { + "current_humidity": "{{ state_attr('sensor.test_state', 'current_humidity') }}", + "current_temperature": "{{ state_attr('sensor.test_state', 'current_temperature') }}", + "fan_mode": "{{ state_attr('sensor.test_state', 'fan_mode') }}", + "fan_modes": "{{ state_attr('sensor.test_state', 'fan_modes') or [] }}", + "set_fan_mode": [], + "hvac_mode": "{{ state_attr('sensor.test_state', 'hvac_mode') }}", + "hvac_modes": "{{ state_attr('sensor.test_state', 'hvac_modes') or [] }}", + "set_hvac_mode": [], + "preset_mode": "{{ state_attr('sensor.test_state', 'preset_mode') }}", + "preset_modes": "{{ state_attr('sensor.test_state', 'preset_modes') or [] }}", + "set_preset_mode": [], + "swing_horizontal_mode": "{{ state_attr('sensor.test_state', 'swing_horizontal_mode') }}", + "swing_horizontal_modes": "{{ state_attr('sensor.test_state', 'swing_horizontal_modes') or [] }}", + "set_swing_horizontal_mode": [], + "swing_mode": "{{ state_attr('sensor.test_state', 'swing_mode') }}", + "swing_modes": "{{ state_attr('sensor.test_state', 'swing_modes') or [] }}", + "set_swing_mode": [], + "target_humidity": "{{ state_attr('sensor.test_state', 'target_humidity') }}", + "target_temperature": "{{ state_attr('sensor.test_state', 'target_temperature') }}", + "target_temperature_high": "{{ state_attr('sensor.test_state', 'target_temperature_high') }}", + "target_temperature_low": "{{ state_attr('sensor.test_state', 'target_temperature_low') }}", + "set_temperature": [], + "set_humidity": [], + }, + "is_state_attr('sensor.test_state', 'hvac_mode', 'heat')", + ) + + assert_state_and_attributes( + hass, + TEST_CLIMATE, + initial_state, + initial_attributes, + ) + + await async_trigger(hass, "sensor.test_state", "x", {"hvac_modes": ["heat"]}) + await async_trigger( + hass, + "sensor.test_state", + "x", + {"hvac_modes": ["heat"], "hvac_mode": HVACMode.HEAT}, + ) + + assert_state_and_attributes(hass, TEST_CLIMATE, HVACMode.HEAT) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_saving_state( + hass: HomeAssistant, + style: ConfigurationStyle, + hass_storage: dict[str, Any], +) -> None: + """Test restore saved state.""" + + await setup_entity( + hass, + TEST_CLIMATE, + style, + 1, + config={ + "hvac_mode": "{{ state_attr('sensor.test_state', 'hvac_mode') }}", + **MINIMUM_REQUIREMENTS, + }, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "anything", + {"hvac_mode": HVACMode.COOL}, + ) + + assert_state_and_attributes( + hass, TEST_CLIMATE, HVACMode.COOL, {"hvac_modes": EXPECTED_HVAC_MODES} + ) + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_CLIMATE.entity_id + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "current_humidity": None, + "current_temperature": None, + "fan_mode": None, + "fan_modes": None, + "hvac_action": None, + "hvac_mode": "cool", + "hvac_modes": EXPECTED_HVAC_MODES, + "preset_mode": None, + "preset_modes": None, + "swing_mode": None, + "swing_modes": None, + "swing_horizontal_mode": None, + "swing_horizontal_modes": None, + "target_humidity": None, + "target_temperature_high": None, + "target_temperature_low": None, + "target_temperature": None, + } + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("action", "config"), + [ + ( + "set_fan_mode", + { + "fan_modes": "{{ ['Disco', 'Police'] }}", + "fan_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_humidity", MINIMUM_REQUIREMENTS), + ("set_hvac_mode", HVAC_MODES), + ( + "set_preset_mode", + { + "preset_modes": "{{ ['Disco', 'Police'] }}", + "preset_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['Disco', 'Police'] }}", + "swing_horizontal_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_mode", + { + "swing_modes": "{{ ['Disco', 'Police'] }}", + "swing_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_temperature", MINIMUM_REQUIREMENTS), + ], +) +async def test_invalid_yaml_actions_do_not_create_entities( + hass: HomeAssistant, + style: ConfigurationStyle, + action: str, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid yaml actions do not create entities.""" + await assert_invalid_yaml_actions_do_not_create_entities( + hass, TEST_CLIMATE, style, config, action, caplog + ) + + +@pytest.mark.parametrize( + ("action", "config"), + [ + ( + "set_fan_mode", + { + "fan_modes": "{{ ['Disco', 'Police'] }}", + "fan_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_humidity", MINIMUM_REQUIREMENTS), + ("set_hvac_mode", HVAC_MODES), + ( + "set_preset_mode", + { + "preset_modes": "{{ ['Disco', 'Police'] }}", + "preset_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_horizontal_mode", + { + "swing_horizontal_modes": "{{ ['Disco', 'Police'] }}", + "swing_horizontal_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ( + "set_swing_mode", + { + "swing_modes": "{{ ['Disco', 'Police'] }}", + "swing_mode": "{{ None }}", + **MINIMUM_REQUIREMENTS, + }, + ), + ("set_temperature", MINIMUM_REQUIREMENTS), + ], +) +async def test_invalid_config_entry_actions_do_not_create_entities( + hass: HomeAssistant, + action: str, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid config entry actions do not create entities.""" + await assert_invalid_config_entry_actions_do_not_create_entities( + hass, TEST_CLIMATE, config, action, caplog + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_extra_template_attributes( + hass: HomeAssistant, style: ConfigurationStyle +) -> None: + """Test extra attributes.""" + await assert_extra_template_attributes( + hass, TEST_CLIMATE, style, MINIMUM_REQUIREMENTS + ) + + +@pytest.mark.parametrize( + "attribute", + list(chain(ClimateEntityCapabilityAttribute, ClimateEntityStateAttribute)), +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_blocked_template_attributes( + hass: HomeAssistant, + style: ConfigurationStyle, + attribute: StrEnum, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test blocked extra attributes.""" + await setup_entity( + hass, + TEST_CLIMATE, + style, + 0, + { + **MINIMUM_REQUIREMENTS, + "attributes": {str(attribute): "{{ 'does not matter' }}"}, + }, + ) + assert ( + f"Unsupported attribute(s) found for {DEFAULT_NAME}: {attribute}" in caplog.text + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_attributes_template( + hass: HomeAssistant, + style: ConfigurationStyle, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes as a single template.""" + await assert_attributes_template( + hass, + TEST_CLIMATE, + style, + MINIMUM_REQUIREMENTS, + caplog, + ) + + +@pytest.mark.parametrize( + "attribute", + list(chain(ClimateEntityCapabilityAttribute, ClimateEntityStateAttribute)), +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_attributes_template_with_blocked_attributes( + hass: HomeAssistant, + style: ConfigurationStyle, + attribute: StrEnum, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test blocked attributes for a single attributes template.""" + await setup_entity( + hass, + TEST_CLIMATE, + style, + 1, + { + **MINIMUM_REQUIREMENTS, + "attributes": f"{{{{ dict({attribute}='does not matter') }}}}", + }, + ) + + await async_trigger(hass, "sensor.test_extra_attributes", "anything") + + error = f"Unsupported attribute(s) found for {TEST_CLIMATE.entity_id}: {attribute}" + assert error in caplog.text diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 5991a94ef8c7..fb5f2ba180f8 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -85,6 +85,22 @@ BINARY_SENSOR_OPTIONS = { {}, {}, ), + ( + "climate", + {"hvac_mode": "{{ states('climate.one') }}"}, + "heat", + {"one": "heat", "two": "cool"}, + {}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + {}, + ), ( "sensor", { @@ -380,6 +396,18 @@ async def test_config_flow( {}, {}, ), + ( + "climate", + {"hvac_mode": "{{ 'heat' }}"}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + ), ( "switch", {"value_template": "{{ false }}"}, @@ -651,6 +679,23 @@ async def test_config_flow_device( "state", None, ), + ( + "climate", + {"hvac_mode": "{{ states('climate.one') }}"}, + {"hvac_mode": "{{ states('climate.two') }}"}, + ["heat", "cool"], + {"one": "heat", "two": "cool"}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + "state", + None, + ), ( "event", {"event_type": "{{ states('event.one') }}"}, @@ -1701,6 +1746,18 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "climate", + {"hvac_mode": "{{ states('climate.one') }}"}, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + { + "hvac_modes": "{{ ['off', 'heat', 'cool', 'heat_cool'] }}", + "set_hvac_mode": [], + }, + ), ( "cover", {"state": "{{ states('cover.one') }}"},