Files
2026-07-03 19:06:58 +02:00

622 lines
19 KiB
Python

"""The tests for the native services of Evohome."""
from datetime import UTC, datetime
from typing import Any
from unittest.mock import patch
from evohomeasync2.const import SZ_DURATION, SZ_MODE, SZ_PERIOD, SZ_SETPOINT, SZ_STATE
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.evohome.climate import EvoZone
from homeassistant.components.evohome.const import (
DOMAIN,
REFRESH_BREAKS_IN_HA_VERSION,
RESET_BREAKS_IN_HA_VERSION,
SERVICE_BREAKS_IN_HA_VERSION,
EvoService,
)
from homeassistant.components.evohome.water_heater import EvoDHW
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES
from homeassistant.setup import async_setup_component
from .const import TEST_INSTALLS
@pytest.mark.parametrize("install", ["default"])
@pytest.mark.usefixtures("evohome")
async def test_refresh_system_deprecated(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test Evohome's refresh_system service.
This service call remains supported during the deprecation window but should cause
a Repair issue.
"""
# EvoService.REFRESH_SYSTEM
with patch("evohomeasync2.location.Location.update") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.REFRESH_SYSTEM,
{},
blocking=True,
)
mock_fcn.assert_awaited_once_with()
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_refresh_system_service")
assert issue is not None
assert issue.translation_key == "deprecated_refresh_system_service"
assert issue.translation_placeholders == {
"breaks_in_ha_version": REFRESH_BREAKS_IN_HA_VERSION,
}
@pytest.mark.parametrize("install", ["default"])
async def test_update_entity(
hass: HomeAssistant,
ctl_id: str,
) -> None:
"""Test that homeassistant.update_entity triggers an appropriate refresh.
Any evohome entity can be targeted; the API invoked by the shared coordinator
refreshes the whole location.
"""
await async_setup_component(hass, "homeassistant", {})
with patch("evohomeasync2.location.Location.update") as mock_fcn:
await hass.services.async_call(
"homeassistant",
"update_entity",
{},
target={ATTR_ENTITY_ID: ctl_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with()
@pytest.mark.parametrize("install", TEST_INSTALLS)
@pytest.mark.usefixtures("evohome")
async def test_reset_system(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test untargeted reset_system service calls."""
# EvoService.RESET_SYSTEM
with patch("evohomeasync2.control_system.ControlSystem.reset") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.RESET_SYSTEM,
{},
blocking=True,
)
mock_fcn.assert_awaited_once_with()
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_reset_system_service")
assert issue is not None
assert issue.translation_key == "deprecated_reset_system_service"
assert issue.translation_placeholders == {
"breaks_in_ha_version": RESET_BREAKS_IN_HA_VERSION,
}
@pytest.mark.parametrize("install", ["default"])
@pytest.mark.usefixtures("evohome")
async def test_set_system_mode_deprecated(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test untargeted set_system_mode service calls.
These untargeted service calls remain supported during the deprecation window but
should cause a Repair issue.
"""
# EvoService.SET_SYSTEM_MODE: Auto
with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
SZ_MODE: "Auto",
},
blocking=True,
)
mock_fcn.assert_awaited_once_with("auto", until=None)
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_set_system_mode_service")
assert issue
assert issue.translation_key == "deprecated_controller_service"
assert issue.translation_placeholders == {
"breaks_in_ha_version": SERVICE_BREAKS_IN_HA_VERSION,
"service": EvoService.SET_SYSTEM_MODE,
}
freezer.move_to("2024-07-10T12:00:00+00:00")
# EvoService.SET_SYSTEM_MODE: AutoWithEco, hours=12
with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
SZ_MODE: "AutoWithEco",
SZ_DURATION: {"hours": 12},
},
blocking=True,
)
mock_fcn.assert_awaited_once_with(
"auto_with_eco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC)
)
# EvoService.SET_SYSTEM_MODE: Away, days=7
with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
SZ_MODE: "Away",
SZ_PERIOD: {"days": 7},
},
blocking=True,
)
mock_fcn.assert_awaited_once_with(
"away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC)
)
@pytest.mark.parametrize("install", ["default"])
async def test_set_system_mode(
hass: HomeAssistant,
ctl_id: str,
issue_registry: ir.IssueRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entity-targeted set_system_mode service calls."""
freezer.move_to("2024-07-10T12:00:00+00:00")
with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
SZ_MODE: "Away",
SZ_PERIOD: {"days": 7},
},
target={ATTR_ENTITY_ID: ctl_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(
"away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC)
)
# can remove, once the domain-level service is removed
with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
ATTR_ENTITY_ID: ctl_id,
SZ_MODE: "Away",
SZ_PERIOD: {"days": 7},
},
blocking=True,
)
mock_fcn.assert_awaited_once_with(
"away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC)
)
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_set_system_mode_service")
assert issue is None
@pytest.mark.parametrize("install", ["default"])
async def test_clear_zone_override(
hass: HomeAssistant,
zone_id: str,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test Evohome's clear_zone_override service (for a heating zone)."""
# EvoZoneMode.FOLLOW_SCHEDULE
with patch("evohomeasync2.zone.Zone.reset") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
{},
target={ATTR_ENTITY_ID: zone_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with()
issue = issue_registry.async_get_issue(
DOMAIN, "deprecated_clear_zone_override_service"
)
assert issue is not None
assert issue.translation_key == "deprecated_clear_zone_override_service"
assert issue.translation_placeholders == {
"breaks_in_ha_version": RESET_BREAKS_IN_HA_VERSION,
}
@pytest.mark.parametrize("install", ["default"])
async def test_clear_zone_override_legacy(
hass: HomeAssistant,
zone_id: str,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test Evohome's clear_zone_override service with the legacy entity_id."""
# EvoZoneMode.FOLLOW_SCHEDULE
with patch("evohomeasync2.zone.Zone.reset") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
{
ATTR_ENTITY_ID: zone_id,
},
blocking=True,
)
mock_fcn.assert_awaited_once_with()
issue = issue_registry.async_get_issue(
DOMAIN, "deprecated_clear_zone_override_service"
)
assert issue is not None
assert issue.translation_key == "deprecated_clear_zone_override_service"
assert issue.translation_placeholders == {
"breaks_in_ha_version": RESET_BREAKS_IN_HA_VERSION,
}
@pytest.mark.parametrize("install", ["default"])
async def test_set_zone_override(
hass: HomeAssistant,
zone_id: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test Evohome's set_zone_override service (for a heating zone)."""
freezer.move_to("2024-07-10T12:00:00+00:00")
# EvoZoneMode.PERMANENT_OVERRIDE
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
SZ_SETPOINT: 19.5,
},
target={ATTR_ENTITY_ID: zone_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(19.5, until=None)
# EvoZoneMode.TEMPORARY_OVERRIDE
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
SZ_SETPOINT: 19.5,
SZ_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"])
async def test_set_zone_override_advance(
hass: HomeAssistant,
zone_id: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test Evohome's set_zone_override service with duration=0.
The override is temporary until the next schedule change.
"""
freezer.move_to("2024-05-10T12:15:00+00:00")
expected_until = datetime(2024, 5, 10, 21, 10, tzinfo=UTC)
# Simulate the schedule not yet having been fetched (e.g. HOMEASSISTANT_START)
entities = hass.data[DATA_DOMAIN_PLATFORM_ENTITIES].get(
(CLIMATE_DOMAIN, DOMAIN), {}
)
zone_entity: EvoZone = entities[zone_id] # type: ignore[assignment]
zone_entity._schedule = None
zone_entity._setpoints = {}
# EvoZoneMode.TEMPORARY_OVERRIDE with duration 0 (i.e. until next schedule change)
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
SZ_SETPOINT: 19.5,
SZ_DURATION: {"minutes": 0},
},
target={ATTR_ENTITY_ID: zone_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(19.5, until=expected_until)
assert zone_entity.setpoints["next_sp_from"] == expected_until
@pytest.mark.parametrize("install", ["default"])
async def test_set_zone_override_legacy(
hass: HomeAssistant,
zone_id: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test Evohome's set_zone_override service with the legacy entity_id."""
freezer.move_to("2024-07-10T12:00:00+00:00")
# EvoZoneMode.PERMANENT_OVERRIDE
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
ATTR_ENTITY_ID: zone_id,
SZ_SETPOINT: 19.5,
},
blocking=True,
)
mock_fcn.assert_awaited_once_with(19.5, until=None)
# EvoZoneMode.TEMPORARY_OVERRIDE
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
{
ATTR_ENTITY_ID: zone_id,
SZ_SETPOINT: 19.5,
SZ_DURATION: {"minutes": 135},
},
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, {SZ_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 service calls with a non-zone entity_id fails."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
service,
service_data,
target={ATTR_ENTITY_ID: ctl_id},
blocking=True,
)
assert exc_info.value.translation_key == "zone_only_service"
assert exc_info.value.translation_placeholders == {"service": service}
@pytest.mark.parametrize("install", ["default"])
async def test_controller_services_with_zone_id(
hass: HomeAssistant,
zone_id: str,
) -> None:
"""Test calling controller-only service calls with a zone entity_id fails."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
SZ_MODE: "Auto",
ATTR_ENTITY_ID: zone_id,
},
blocking=True,
)
assert exc_info.value.translation_key == "controller_only_service"
assert exc_info.value.translation_placeholders == {
"service": EvoService.SET_SYSTEM_MODE,
}
@pytest.mark.parametrize("install", ["default"])
@pytest.mark.usefixtures("evohome")
async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None:
"""Test set_system_mode with a non-existent entity_id raises entity_not_found."""
non_existent_entity_id = "climate.non_existent_entity"
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
{
SZ_MODE: "Auto",
ATTR_ENTITY_ID: non_existent_entity_id,
},
blocking=True,
)
assert exc_info.value.translation_key == "entity_not_found"
assert exc_info.value.translation_placeholders == {
ATTR_ENTITY_ID: non_existent_entity_id,
}
_SET_SYSTEM_MODE_VALIDATOR_PARAMS = [
(
{SZ_MODE: "NotARealMode"},
"mode_not_supported",
),
(
{SZ_MODE: "Auto", SZ_DURATION: {"hours": 1}},
"mode_cant_be_temporary",
),
(
{SZ_MODE: "AutoWithEco", SZ_PERIOD: {"days": 1}},
"mode_cant_have_period",
),
(
{SZ_MODE: "DayOff", SZ_DURATION: {"hours": 1}},
"mode_cant_have_duration",
),
]
@pytest.mark.parametrize("install", ["default"])
@pytest.mark.usefixtures("evohome")
@pytest.mark.parametrize(
("service_data", "expected_translation_key"),
_SET_SYSTEM_MODE_VALIDATOR_PARAMS,
ids=[k for _, k in _SET_SYSTEM_MODE_VALIDATOR_PARAMS],
)
async def test_set_system_mode_validator(
hass: HomeAssistant,
service_data: dict[str, Any],
expected_translation_key: str,
) -> None:
"""Test ServiceValidationError for all controller system mode validation cases."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
service_data,
blocking=True,
)
assert exc_info.value.translation_key == expected_translation_key
assert exc_info.value.translation_placeholders == {SZ_MODE: service_data[SZ_MODE]}
@pytest.mark.parametrize("install", ["default"])
async def test_set_dhw_override(
hass: HomeAssistant,
dhw_id: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test Evohome's set_dhw_override service (for a DHW zone)."""
freezer.move_to("2024-07-10T12:00:00+00:00")
# EvoZoneMode.PERMANENT_OVERRIDE (off)
with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_DHW_OVERRIDE,
{
SZ_STATE: False,
},
target={ATTR_ENTITY_ID: dhw_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(until=None)
# EvoZoneMode.TEMPORARY_OVERRIDE (on)
with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_DHW_OVERRIDE,
{
SZ_STATE: True,
SZ_DURATION: {"minutes": 135},
},
target={ATTR_ENTITY_ID: dhw_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(
until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC)
)
@pytest.mark.parametrize("install", ["default"])
async def test_set_dhw_override_advance(
hass: HomeAssistant,
dhw_id: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test Evohome's set_dhw_override service with duration=0.
The override is temporary until the next schedule change.
"""
freezer.move_to("2024-05-10T12:15:00+00:00")
expected_until = datetime(2024, 5, 10, 15, 30, tzinfo=UTC)
# Simulate the schedule not yet having been fetched (e.g. HOMEASSISTANT_START)
entities = hass.data[DATA_DOMAIN_PLATFORM_ENTITIES].get(
(WATER_HEATER_DOMAIN, DOMAIN), {}
)
dhw_entity: EvoDHW = entities[dhw_id] # type: ignore[assignment]
dhw_entity._schedule = None
dhw_entity._setpoints = {}
# EvoZoneMode.TEMPORARY_OVERRIDE with duration 0 (i.e. until next schedule change)
with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn:
await hass.services.async_call(
DOMAIN,
EvoService.SET_DHW_OVERRIDE,
{
SZ_STATE: True,
SZ_DURATION: {"minutes": 0},
},
target={ATTR_ENTITY_ID: dhw_id},
blocking=True,
)
mock_fcn.assert_awaited_once_with(until=expected_until)
assert dhw_entity.setpoints["next_sp_from"] == expected_until