diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 39ed1bc4d9f8..9af964ae4c3c 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -5,11 +5,13 @@ import functools as ft import logging from typing import Any, Literal, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( + +# ATTR_TEMPERATURE and the SERVICE_* constants are re-exported for integrations +# importing them from the climate component root. +from homeassistant.const import ( # noqa: F401 ATTR_TEMPERATURE, PRECISION_TENTHS, PRECISION_WHOLE, @@ -18,14 +20,13 @@ from homeassistant.const import ( SERVICE_TURN_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 @@ -51,6 +52,7 @@ from .const import ( # noqa: F401 ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, + DATA_COMPONENT, DOMAIN, FAN_AUTO, FAN_DIFFUSE, @@ -91,10 +93,10 @@ from .const import ( # noqa: F401 HVACAction, HVACMode, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -DATA_COMPONENT: HassKey[EntityComponent[ClimateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -105,25 +107,6 @@ DEFAULT_MAX_TEMP = 35 DEFAULT_MIN_HUMIDITY = 30 DEFAULT_MAX_HUMIDITY = 99 -CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] - -SET_TEMPERATURE_SCHEMA = probatio.All( - cv.has_at_least_one_key( - ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW - ), - cv.make_entity_service_schema( - { - probatio.Exclusive(ATTR_TEMPERATURE, "temperature"): probatio.Coerce(float), - probatio.Inclusive(ATTR_TARGET_TEMP_HIGH, "temperature"): probatio.Coerce( - float - ), - probatio.Inclusive(ATTR_TARGET_TEMP_LOW, "temperature"): probatio.Coerce( - float - ), - probatio.Optional(ATTR_HVAC_MODE): probatio.Coerce(HVACMode), - } - ), -) # mypy: disallow-any-generics @@ -135,68 +118,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_TURN_ON, - None, - "async_turn_on", - [ClimateEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, - None, - "async_turn_off", - [ClimateEntityFeature.TURN_OFF], - ) - component.async_register_entity_service( - SERVICE_TOGGLE, - None, - "async_toggle", - [ClimateEntityFeature.TURN_OFF, ClimateEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_SET_HVAC_MODE, - {probatio.Required(ATTR_HVAC_MODE): probatio.Coerce(HVACMode)}, - "async_handle_set_hvac_mode_service", - ) - component.async_register_entity_service( - SERVICE_SET_PRESET_MODE, - {probatio.Required(ATTR_PRESET_MODE): cv.string}, - "async_handle_set_preset_mode_service", - [ClimateEntityFeature.PRESET_MODE], - ) - component.async_register_entity_service( - SERVICE_SET_TEMPERATURE, - SET_TEMPERATURE_SCHEMA, - async_service_temperature_set, - [ - ClimateEntityFeature.TARGET_TEMPERATURE, - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ], - ) - component.async_register_entity_service( - SERVICE_SET_HUMIDITY, - {probatio.Required(ATTR_HUMIDITY): probatio.Coerce(int)}, - async_service_humidity_set, - [ClimateEntityFeature.TARGET_HUMIDITY], - ) - component.async_register_entity_service( - SERVICE_SET_FAN_MODE, - {probatio.Required(ATTR_FAN_MODE): cv.string}, - "async_handle_set_fan_mode_service", - [ClimateEntityFeature.FAN_MODE], - ) - component.async_register_entity_service( - SERVICE_SET_SWING_MODE, - {probatio.Required(ATTR_SWING_MODE): cv.string}, - "async_handle_set_swing_mode_service", - [ClimateEntityFeature.SWING_MODE], - ) - component.async_register_entity_service( - SERVICE_SET_SWING_HORIZONTAL_MODE, - {probatio.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string}, - "async_handle_set_swing_horizontal_mode_service", - [ClimateEntityFeature.SWING_HORIZONTAL_MODE], - ) + async_setup_services(hass) return True @@ -760,102 +682,3 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def target_humidity_step(self) -> int | None: """Return the supported step of humidity.""" return self._attr_target_humidity_step - - -async def async_service_humidity_set( - entity: ClimateEntity, service_call: ServiceCall -) -> None: - """Handle set humidity service.""" - humidity = service_call.data[ATTR_HUMIDITY] - min_humidity = entity.min_humidity - max_humidity = entity.max_humidity - _LOGGER.debug( - "Check valid humidity %d in range %d - %d", - humidity, - min_humidity, - max_humidity, - ) - if humidity < min_humidity or humidity > max_humidity: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="humidity_out_of_range", - translation_placeholders={ - "humidity": str(humidity), - "min_humidity": str(min_humidity), - "max_humidity": str(max_humidity), - }, - ) - - await entity.async_set_humidity(humidity) - - -async def async_service_temperature_set( - entity: ClimateEntity, service_call: ServiceCall -) -> None: - """Handle set temperature service.""" - if ( - ATTR_TEMPERATURE in service_call.data - and not entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_target_temperature_entity_feature", - ) - if ( - ATTR_TARGET_TEMP_LOW in service_call.data - and not entity.supported_features - & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_target_temperature_range_entity_feature", - ) - - hass = entity.hass - kwargs: dict[str, Any] = {} - min_temp = entity.min_temp - max_temp = entity.max_temp - temp_unit = entity.temperature_unit - - if ( - (target_low_temp := service_call.data.get(ATTR_TARGET_TEMP_LOW)) - and (target_high_temp := service_call.data.get(ATTR_TARGET_TEMP_HIGH)) - and target_low_temp > target_high_temp - ): - # Ensure target_low_temp is not higher than target_high_temp. - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="low_temp_higher_than_high_temp", - ) - - for value, temp in service_call.data.items(): - if value in CONVERTIBLE_ATTRIBUTE: - kwargs[value] = check_temp = TemperatureConverter.convert( - temp, hass.config.units.temperature_unit, temp_unit - ) - - _LOGGER.debug( - "Check valid temperature %d %s (%d %s) in range %d %s - %d %s", - check_temp, - entity.temperature_unit, - temp, - hass.config.units.temperature_unit, - min_temp, - temp_unit, - max_temp, - temp_unit, - ) - if check_temp < min_temp or check_temp > max_temp: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="temp_out_of_range", - translation_placeholders={ - "check_temp": str(check_temp), - "min_temp": str(min_temp), - "max_temp": str(max_temp), - }, - ) - else: - kwargs[value] = temp - - await entity.async_set_temperature(**kwargs) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 8249f40fd7c6..9e83cb215cbf 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,12 +1,20 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from typing import Final +from typing import TYPE_CHECKING, Final from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import ClimateEntity DOMAIN: Final = "climate" +DATA_COMPONENT: HassKey[EntityComponent[ClimateEntity]] = HassKey(DOMAIN) + class HVACMode(StrEnum): """HVAC mode for climate devices.""" diff --git a/homeassistant/components/climate/services.py b/homeassistant/components/climate/services.py new file mode 100644 index 000000000000..39888b20eefc --- /dev/null +++ b/homeassistant/components/climate/services.py @@ -0,0 +1,233 @@ +"""Services for the climate integration.""" + +import logging +from typing import TYPE_CHECKING, Any + +import probatio + +from homeassistant.const import ( + ATTR_TEMPERATURE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.util.unit_conversion import TemperatureConverter + +from .const import ( + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DATA_COMPONENT, + DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACMode, +) + +if TYPE_CHECKING: + from . import ClimateEntity + +_LOGGER = logging.getLogger(__name__) + +CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] + + +SET_TEMPERATURE_SCHEMA = probatio.All( + cv.has_at_least_one_key( + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW + ), + cv.make_entity_service_schema( + { + probatio.Exclusive(ATTR_TEMPERATURE, "temperature"): probatio.Coerce(float), + probatio.Inclusive(ATTR_TARGET_TEMP_HIGH, "temperature"): probatio.Coerce( + float + ), + probatio.Inclusive(ATTR_TARGET_TEMP_LOW, "temperature"): probatio.Coerce( + float + ), + probatio.Optional(ATTR_HVAC_MODE): probatio.Coerce(HVACMode), + } + ), +) + + +async def _async_service_humidity_set( + entity: ClimateEntity, service_call: ServiceCall +) -> None: + """Handle set humidity service.""" + humidity = service_call.data[ATTR_HUMIDITY] + min_humidity = entity.min_humidity + max_humidity = entity.max_humidity + _LOGGER.debug( + "Check valid humidity %d in range %d - %d", + humidity, + min_humidity, + max_humidity, + ) + if humidity < min_humidity or humidity > max_humidity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="humidity_out_of_range", + translation_placeholders={ + "humidity": str(humidity), + "min_humidity": str(min_humidity), + "max_humidity": str(max_humidity), + }, + ) + + await entity.async_set_humidity(humidity) + + +async def _async_service_temperature_set( + entity: ClimateEntity, service_call: ServiceCall +) -> None: + """Handle set temperature service.""" + if ( + ATTR_TEMPERATURE in service_call.data + and not entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_target_temperature_entity_feature", + ) + if ( + ATTR_TARGET_TEMP_LOW in service_call.data + and not entity.supported_features + & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_target_temperature_range_entity_feature", + ) + + hass = entity.hass + kwargs: dict[str, Any] = {} + min_temp = entity.min_temp + max_temp = entity.max_temp + temp_unit = entity.temperature_unit + + if ( + (target_low_temp := service_call.data.get(ATTR_TARGET_TEMP_LOW)) + and (target_high_temp := service_call.data.get(ATTR_TARGET_TEMP_HIGH)) + and target_low_temp > target_high_temp + ): + # Ensure target_low_temp is not higher than target_high_temp. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="low_temp_higher_than_high_temp", + ) + + for value, temp in service_call.data.items(): + if value in CONVERTIBLE_ATTRIBUTE: + kwargs[value] = check_temp = TemperatureConverter.convert( + temp, hass.config.units.temperature_unit, temp_unit + ) + + _LOGGER.debug( + "Check valid temperature %d %s (%d %s) in range %d %s - %d %s", + check_temp, + entity.temperature_unit, + temp, + hass.config.units.temperature_unit, + min_temp, + temp_unit, + max_temp, + temp_unit, + ) + if check_temp < min_temp or check_temp > max_temp: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_out_of_range", + translation_placeholders={ + "check_temp": str(check_temp), + "min_temp": str(min_temp), + "max_temp": str(max_temp), + }, + ) + else: + kwargs[value] = temp + + await entity.async_set_temperature(**kwargs) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the climate services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_TURN_ON, + None, + "async_turn_on", + [ClimateEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, + None, + "async_turn_off", + [ClimateEntityFeature.TURN_OFF], + ) + component.async_register_entity_service( + SERVICE_TOGGLE, + None, + "async_toggle", + [ClimateEntityFeature.TURN_OFF, ClimateEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_SET_HVAC_MODE, + {probatio.Required(ATTR_HVAC_MODE): probatio.Coerce(HVACMode)}, + "async_handle_set_hvac_mode_service", + ) + component.async_register_entity_service( + SERVICE_SET_PRESET_MODE, + {probatio.Required(ATTR_PRESET_MODE): cv.string}, + "async_handle_set_preset_mode_service", + [ClimateEntityFeature.PRESET_MODE], + ) + component.async_register_entity_service( + SERVICE_SET_TEMPERATURE, + SET_TEMPERATURE_SCHEMA, + _async_service_temperature_set, + [ + ClimateEntityFeature.TARGET_TEMPERATURE, + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ], + ) + component.async_register_entity_service( + SERVICE_SET_HUMIDITY, + {probatio.Required(ATTR_HUMIDITY): probatio.Coerce(int)}, + _async_service_humidity_set, + [ClimateEntityFeature.TARGET_HUMIDITY], + ) + component.async_register_entity_service( + SERVICE_SET_FAN_MODE, + {probatio.Required(ATTR_FAN_MODE): cv.string}, + "async_handle_set_fan_mode_service", + [ClimateEntityFeature.FAN_MODE], + ) + component.async_register_entity_service( + SERVICE_SET_SWING_MODE, + {probatio.Required(ATTR_SWING_MODE): cv.string}, + "async_handle_set_swing_mode_service", + [ClimateEntityFeature.SWING_MODE], + ) + component.async_register_entity_service( + SERVICE_SET_SWING_HORIZONTAL_MODE, + {probatio.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string}, + "async_handle_set_swing_horizontal_mode_service", + [ClimateEntityFeature.SWING_HORIZONTAL_MODE], + ) diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index c87e89ec288a..2576fbf1e235 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -7,12 +7,7 @@ from unittest.mock import MagicMock, Mock import probatio import pytest -from homeassistant.components.climate import ( - DOMAIN, - SET_TEMPERATURE_SCHEMA, - ClimateEntity, - HVACMode, -) +from homeassistant.components.climate import DOMAIN, ClimateEntity, HVACMode from homeassistant.components.climate.const import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -36,6 +31,7 @@ from homeassistant.components.climate.const import ( SWING_HORIZONTAL_ON, ClimateEntityFeature, ) +from homeassistant.components.climate.services import SET_TEMPERATURE_SCHEMA from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError