Migrate evohome's zone services to entity-level services (#164105)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
David Bonnes
2026-02-27 09:16:35 +00:00
committed by GitHub
parent f8a657cf01
commit 46a87cd9dd
6 changed files with 120 additions and 112 deletions

View File

@@ -36,12 +36,12 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, EVOHOME_DATA, EvoService
from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService
from .coordinator import EvoDataUpdateCoordinator
from .entity import EvoChild, EvoEntity
@@ -132,6 +132,24 @@ class EvoClimateEntity(EvoEntity, ClimateEntity):
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
async def async_clear_zone_override(self) -> None:
"""Clear the zone override; only supported by zones."""
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="zone_only_service",
translation_placeholders={"service": EvoService.CLEAR_ZONE_OVERRIDE},
)
async def async_set_zone_override(
self, setpoint: float, duration: timedelta | None = None
) -> None:
"""Set the zone override; only supported by zones."""
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="zone_only_service",
translation_placeholders={"service": EvoService.SET_ZONE_OVERRIDE},
)
class EvoZone(EvoChild, EvoClimateEntity):
"""Base for any evohome-compatible heating zone."""
@@ -170,22 +188,22 @@ class EvoZone(EvoChild, EvoClimateEntity):
| ClimateEntityFeature.TURN_ON
)
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (setpoint override) for a zone."""
if service == EvoService.CLEAR_ZONE_OVERRIDE:
await self.coordinator.call_client_api(self._evo_device.reset())
return
async def async_clear_zone_override(self) -> None:
"""Clear the zone's override, if any."""
await self.coordinator.call_client_api(self._evo_device.reset())
# otherwise it is EvoService.SET_ZONE_OVERRIDE
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
async def async_set_zone_override(
self, setpoint: float, duration: timedelta | None = None
) -> None:
"""Set the zone's override (mode/setpoint)."""
temperature = max(min(setpoint, self.max_temp), self.min_temp)
if ATTR_DURATION in data:
duration: timedelta = data[ATTR_DURATION]
if duration is not None:
if duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
else:
until = dt_util.now() + data[ATTR_DURATION]
until = dt_util.now() + duration
else:
until = None # indefinitely

View File

@@ -12,7 +12,7 @@ from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, EvoService
from .const import DOMAIN
from .coordinator import EvoDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -47,22 +47,12 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
raise NotImplementedError
if payload["unique_id"] != self._attr_unique_id:
return
if payload["service"] in (
EvoService.SET_ZONE_OVERRIDE,
EvoService.CLEAR_ZONE_OVERRIDE,
):
await self.async_zone_svc_request(payload["service"], payload["data"])
return
await self.async_tcs_svc_request(payload["service"], payload["data"])
async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (system mode) for a controller."""
raise NotImplementedError
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (setpoint override) for a zone."""
raise NotImplementedError
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the evohome-specific state attributes."""

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import Final
from typing import Any, Final
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
from evohomeasync2.schemas.const import (
@@ -13,9 +13,10 @@ from evohomeasync2.schemas.const import (
)
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
@@ -25,21 +26,38 @@ from .coordinator import EvoDataUpdateCoordinator
# system mode schemas are built dynamically when the services are registered
# because supported modes can vary for edge-case systems
CLEAR_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{vol.Required(ATTR_ENTITY_ID): cv.entity_id}
)
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
}
)
# Zone service schemas (registered as entity services)
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
),
}
def _register_zone_entity_services(hass: HomeAssistant) -> None:
"""Register entity-level services for zones."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
entity_domain=CLIMATE_DOMAIN,
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
func="async_clear_zone_override",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
entity_domain=CLIMATE_DOMAIN,
schema=SET_ZONE_OVERRIDE_SCHEMA,
func="async_set_zone_override",
)
@callback
@@ -51,8 +69,6 @@ def setup_service_functions(
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
each mode will require any of four distinct service schemas. This has to be
enumerated before registering the appropriate handlers.
It appears that all TCC-compatible systems support the same three zones modes.
"""
@verify_domain_control(DOMAIN)
@@ -72,28 +88,6 @@ def setup_service_functions(
}
async_dispatcher_send(hass, DOMAIN, payload)
@verify_domain_control(DOMAIN)
async def set_zone_override(call: ServiceCall) -> None:
"""Set the zone override (setpoint)."""
entity_id = call.data[ATTR_ENTITY_ID]
registry = er.async_get(hass)
registry_entry = registry.async_get(entity_id)
if registry_entry is None or registry_entry.platform != DOMAIN:
raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
if registry_entry.domain != "climate":
raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone")
payload = {
"unique_id": registry_entry.unique_id,
"service": call.service,
"data": call.data,
}
async_dispatcher_send(hass, DOMAIN, payload)
assert coordinator.tcs is not None # mypy
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
@@ -156,16 +150,4 @@ def setup_service_functions(
schema=vol.Schema(vol.Any(*system_mode_schemas)),
)
# The zone modes are consistent across all systems and use the same schema
hass.services.async_register(
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
set_zone_override,
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
set_zone_override,
schema=SET_ZONE_OVERRIDE_SCHEMA,
)
_register_zone_entity_services(hass)

View File

@@ -28,14 +28,11 @@ reset_system:
refresh_system:
set_zone_override:
target:
entity:
integration: evohome
domain: climate
fields:
entity_id:
required: true
example: climate.bathroom
selector:
entity:
integration: evohome
domain: climate
setpoint:
required: true
selector:
@@ -49,10 +46,7 @@ set_zone_override:
object:
clear_zone_override:
fields:
entity_id:
required: true
selector:
entity:
integration: evohome
domain: climate
target:
entity:
integration: evohome
domain: climate

View File

@@ -1,13 +1,12 @@
{
"exceptions": {
"zone_only_service": {
"message": "Only zones support the `{service}` service"
}
},
"services": {
"clear_zone_override": {
"description": "Sets a zone to follow its schedule.",
"fields": {
"entity_id": {
"description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]",
"name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]"
}
},
"name": "Clear zone override"
},
"refresh_system": {
@@ -43,10 +42,6 @@
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",
"name": "Duration"
},
"entity_id": {
"description": "The entity ID of the Evohome zone.",
"name": "Entity"
},
"setpoint": {
"description": "The temperature to be used instead of the scheduled setpoint.",
"name": "Setpoint"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import patch
from evohomeasync2 import EvohomeClient
@@ -18,10 +19,11 @@ from homeassistant.components.evohome.const import (
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
@pytest.mark.parametrize("install", ["default"])
async def test_service_refresh_system(
async def test_refresh_system(
hass: HomeAssistant,
evohome: EvohomeClient,
) -> None:
@@ -40,7 +42,7 @@ async def test_service_refresh_system(
@pytest.mark.parametrize("install", ["default"])
async def test_service_reset_system(
async def test_reset_system(
hass: HomeAssistant,
ctl_id: str,
) -> None:
@@ -59,7 +61,7 @@ async def test_service_reset_system(
@pytest.mark.parametrize("install", ["default"])
async def test_ctl_set_system_mode(
async def test_set_system_mode(
hass: HomeAssistant,
ctl_id: str,
freezer: FrozenDateTimeFactory,
@@ -115,7 +117,7 @@ async def test_ctl_set_system_mode(
@pytest.mark.parametrize("install", ["default"])
async def test_zone_clear_zone_override(
async def test_clear_zone_override(
hass: HomeAssistant,
zone_id: str,
) -> None:
@@ -126,9 +128,8 @@ async def test_zone_clear_zone_override(
await hass.services.async_call(
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
{
ATTR_ENTITY_ID: zone_id,
},
{},
target={ATTR_ENTITY_ID: zone_id},
blocking=True,
)
@@ -136,7 +137,7 @@ async def test_zone_clear_zone_override(
@pytest.mark.parametrize("install", ["default"])
async def test_zone_set_zone_override(
async def test_set_zone_override(
hass: HomeAssistant,
zone_id: str,
freezer: FrozenDateTimeFactory,
@@ -151,9 +152,9 @@ async def test_zone_set_zone_override(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
ATTR_ENTITY_ID: zone_id,
ATTR_SETPOINT: 19.5,
},
target={ATTR_ENTITY_ID: zone_id},
blocking=True,
)
@@ -165,13 +166,41 @@ async def test_zone_set_zone_override(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
ATTR_ENTITY_ID: zone_id,
ATTR_SETPOINT: 19.5,
ATTR_DURATION: {"minutes": 135},
},
target={ATTR_ENTITY_ID: zone_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(
19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC)
)
@pytest.mark.parametrize("install", ["default"])
@pytest.mark.parametrize(
("service", "service_data"),
[
(EvoService.CLEAR_ZONE_OVERRIDE, {}),
(EvoService.SET_ZONE_OVERRIDE, {ATTR_SETPOINT: 19.5}),
],
)
async def test_zone_services_with_ctl_id(
hass: HomeAssistant,
ctl_id: str,
service: EvoService,
service_data: dict[str, Any],
) -> None:
"""Test calling zone-only services with a non-zone entity_id fail."""
with pytest.raises(ServiceValidationError) as excinfo:
await hass.services.async_call(
DOMAIN,
service,
service_data,
target={ATTR_ENTITY_ID: ctl_id},
blocking=True,
)
assert excinfo.value.translation_key == "zone_only_service"