Move climate service registration to services module (#183410)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
epenet
2026-09-28 09:51:36 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 74aca06a65
commit 2bbe65711a
4 changed files with 252 additions and 192 deletions
+8 -185
View File
@@ -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)
+9 -1
View File
@@ -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."""
@@ -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],
)
+2 -6
View File
@@ -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