"""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