diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py index 0042091ce587..48a5ce8f7704 100644 --- a/homeassistant/components/eurotronic_cometblue/__init__.py +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -8,11 +8,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .services import async_setup_services +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CLIMATE, @@ -77,6 +80,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) -> return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Eurotronic Comet Blue integration.""" + async_setup_services(hass) + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eurotronic_cometblue/icons.json b/homeassistant/components/eurotronic_cometblue/icons.json index 5b418f4b2dbf..a5d688ffc945 100644 --- a/homeassistant/components/eurotronic_cometblue/icons.json +++ b/homeassistant/components/eurotronic_cometblue/icons.json @@ -16,5 +16,16 @@ "default": "mdi:thermometer-check" } } + }, + "services": { + "get_schedule": { + "service": "mdi:calendar-search" + }, + "set_holiday": { + "service": "mdi:beach" + }, + "set_schedule": { + "service": "mdi:calendar-edit" + } } } diff --git a/homeassistant/components/eurotronic_cometblue/quality_scale.yaml b/homeassistant/components/eurotronic_cometblue/quality_scale.yaml index 165d8ef07574..9e6cb18fd739 100644 --- a/homeassistant/components/eurotronic_cometblue/quality_scale.yaml +++ b/homeassistant/components/eurotronic_cometblue/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: This integration does not provide actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: This integration does not provide actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -32,9 +28,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: This integration does not provide actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -45,7 +39,7 @@ rules: reauthentication-flow: status: exempt comment: This integration does not login to any device or service. - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/eurotronic_cometblue/services.py b/homeassistant/components/eurotronic_cometblue/services.py new file mode 100644 index 000000000000..c3f4ab94986c --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/services.py @@ -0,0 +1,270 @@ +"""Comet Blue services.""" + +from datetime import time, timedelta +import logging +from typing import Final, TypedDict, cast + +import voluptuous as vol + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, service +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonArrayType, JsonObjectType + +from .climate import MAX_TEMP, MIN_TEMP +from .const import DOMAIN +from .entity import CometBlueBluetoothEntity + +LOGGER = logging.getLogger(__name__) + + +ATTR_SCHEDULE: Final = "schedule" +ATTR_MONDAY: Final = "monday" +ATTR_TUESDAY: Final = "tuesday" +ATTR_WEDNESDAY: Final = "wednesday" +ATTR_THURSDAY: Final = "thursday" +ATTR_FRIDAY: Final = "friday" +ATTR_SATURDAY: Final = "saturday" +ATTR_SUNDAY: Final = "sunday" +ATTR_DATA: Final = "data" +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_FROM: Final = "from" +ATTR_TO: Final = "to" + +ATTR_ALL_DAYS: Final = [ + ATTR_MONDAY, + ATTR_TUESDAY, + ATTR_WEDNESDAY, + ATTR_THURSDAY, + ATTR_FRIDAY, + ATTR_SATURDAY, + ATTR_SUNDAY, +] + +ScheduleEntry = TypedDict( + "ScheduleEntry", + { + "from": time, + "to": time, + }, + total=False, +) + + +def _validate_half_precision(value: float) -> float: + """Return True if the value is a half precision float.""" + + try: + r = value % 0.5 + if r != 0: + raise ServiceValidationError( + f"value {value} is not a half precision float, remainder is {r}" + ) + except TypeError as err: + raise ServiceValidationError(f"value {value} is not a float") from err + return value + + +def _validate_cometblue_schedule( + schedule: list[ScheduleEntry], +) -> dict[str, time] | None: + """Validate day schedule time ranges. + + Ensure they have no overlap and the end time is greater than the start time. + """ + if not schedule: + return {} + + schedule = sorted( + schedule, + key=lambda entry: entry.get(ATTR_FROM, time.min), + ) + + normalized_schedule: dict[str, time] = {} + previous_to: time | None = None + for i, entry in enumerate(schedule, start=1): + start = entry.get(ATTR_FROM) + end = entry.get(ATTR_TO) + + if start is None or end is None: + raise ServiceValidationError("Missing from/to in entry") + + # Check if the start time of the current event is before the end time of the current event + if start >= end: + raise ServiceValidationError( + f"Invalid time range {i}, {start} is after {end}" + ) + + # Check if the from time of the event is after the to time of the previous event + if previous_to is not None and previous_to > start: + raise ServiceValidationError( + f"Overlapping times found in schedule, {start} is earlier than previous entry {previous_to} ends" + ) + + normalized_schedule[f"{ATTR_START}{i}"] = start + normalized_schedule[f"{ATTR_END}{i}"] = end + previous_to = end + + return normalized_schedule + + +SCHEDULE_ENTRY_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_FROM): cv.time, + vol.Optional(ATTR_TO): cv.time, + }, + extra=vol.REMOVE_EXTRA, +) +SCHEDULE_DAY_SCHEMA = vol.All( + [SCHEDULE_ENTRY_SCHEMA], + vol.Length(max=4), + _validate_cometblue_schedule, +) +SERVICE_SCHEDULE_SCHEMA = { + vol.Optional(day): SCHEDULE_DAY_SCHEMA for day in ATTR_ALL_DAYS +} +SERVICE_HOLIDAY_SCHEMA = { + vol.Required(ATTR_FROM): cv.datetime, + vol.Required(ATTR_TO): cv.datetime, + vol.Required(ATTR_TEMPERATURE): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMP, max=MAX_TEMP), + _validate_half_precision, + ), +} + + +async def get_schedule( + entity: CometBlueBluetoothEntity, service_call: ServiceCall +) -> ServiceResponse: + """Service call to retrieve the schedule from the device.""" + device_schedule = cast( + dict[str, dict[str, str] | None], + await entity.coordinator.send_command( + entity.coordinator.device.get_multiple_async, + {"values": ["weekdays"]}, + ), + ) + + week_schedule: JsonObjectType = {} + for day in ATTR_ALL_DAYS: + day_schedule = device_schedule.get(day, {}) + if day_schedule: + curr_day_schedule: JsonArrayType = [ + { + ATTR_FROM: start, + ATTR_TO: end, + } + for i in range(1, 5) + if (start := day_schedule.get(f"{ATTR_START}{i}")) is not None + and (end := day_schedule.get(f"{ATTR_END}{i}")) is not None + ] + week_schedule[day] = curr_day_schedule + + return week_schedule + + +async def set_schedule( + entity: CometBlueBluetoothEntity, service_call: ServiceCall +) -> None: + """Service call to update the schedule on the device.""" + LOGGER.info( + "Setting schedule for %s (%s) on days: %s", + entity.entity_id, + entity.coordinator.device.device.address, + ", ".join( + day for day in ATTR_ALL_DAYS if service_call.data.get(day) is not None + ), + ) + for day in ATTR_ALL_DAYS: + LOGGER.debug( + "Settings schedule for %s: %s", + day, + service_call.data.get(day), + ) + values = { + day: {k: v.strftime("%H:%M") for k, v in sched.items()} + for day, sched in service_call.data.items() + if sched is not None and day in ATTR_ALL_DAYS + } + await entity.coordinator.send_command( + entity.coordinator.device.set_weekdays_async, + {"values": values}, + ) + + +async def set_holiday( + entity: CometBlueBluetoothEntity, service_call: ServiceCall +) -> None: + """Service call to update the holiday time on the device.""" + # ceil the start time to the next full hour + away_start = service_call.data[ATTR_FROM].replace( + minute=0, second=0, microsecond=0 + ) + timedelta(hours=1) + + if away_start < dt_util.naive_now(): + raise ServiceValidationError( + "Start date (ceiled to next hour) must be in the future" + ) + + LOGGER.info( + "Setting holiday for %s (%s) until %s with temperature %s", + entity.entity_id, + entity.coordinator.device.device.address, + service_call.data[ATTR_TO], + service_call.data[ATTR_TEMPERATURE], + ) + await entity.coordinator.send_command( + entity.coordinator.device.set_holiday_async, + { + "number": 1, + "values": { + ATTR_START: service_call.data[ATTR_FROM], + ATTR_END: service_call.data[ATTR_TO], + ATTR_TEMPERATURE: service_call.data[ATTR_TEMPERATURE], + }, + }, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Eurotronic Comet Blue services.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "get_schedule", + entity_domain=CLIMATE_DOMAIN, + schema=None, + supports_response=SupportsResponse.ONLY, + func=get_schedule, + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_schedule", + entity_domain=CLIMATE_DOMAIN, + schema=cv.make_entity_service_schema(SERVICE_SCHEDULE_SCHEMA), + supports_response=SupportsResponse.NONE, + func=set_schedule, + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_holiday", + entity_domain=CLIMATE_DOMAIN, + schema=cv.make_entity_service_schema(SERVICE_HOLIDAY_SCHEMA), + supports_response=SupportsResponse.NONE, + func=set_holiday, + ) diff --git a/homeassistant/components/eurotronic_cometblue/services.yaml b/homeassistant/components/eurotronic_cometblue/services.yaml new file mode 100644 index 000000000000..15f5eb0dcf59 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/services.yaml @@ -0,0 +1,215 @@ +get_schedule: + target: + entity: + domain: climate + integration: eurotronic_cometblue + +set_schedule: + target: + entity: + domain: climate + integration: eurotronic_cometblue + fields: + monday: + example: | + - from: 07:00:00 + to: 09:00:00 + - from: 10:00:00 + to: 12:00:00 + - from: 13:00:00 + to: 17:00:00 + - from: 20:00:00 + to: 23:00:00 + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + tuesday: + example: | + - from: 07:00:00 + to: 09:00:00 + - from: 10:00:00 + to: 12:00:00 + - from: 13:00:00 + to: 17:00:00 + - from: 20:00:00 + to: 23:00:00 + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + wednesday: + example: | + - from: 07:00:00 + to: 09:00:00 + - from: 10:00:00 + to: 12:00:00 + - from: 13:00:00 + to: 17:00:00 + - from: 20:00:00 + to: 23:00:00 + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + thursday: + example: | + - from: 07:00:00 + to: 09:00:00 + - from: 10:00:00 + to: 12:00:00 + - from: 13:00:00 + to: 17:00:00 + - from: 20:00:00 + to: 23:00:00 + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + friday: + example: [] + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + saturday: + example: | + - from: 09:00:00 + to: 12:00:00 + - from: 17:00:00 + to: 19:00:00 + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + sunday: + example: | + - from: 07:00:00 + to: 09:00:00 + - from: 10:00:00 + to: 12:00:00 + - from: 13:00:00 + to: 17:00:00 + - from: 20:00:00 + to: 23:00:00 + required: false + selector: + object: + label_field: From + description_field: To + multiple: true + fields: + from: + label: From + required: false + selector: + time: + to: + label: To + required: false + selector: + time: + +set_holiday: + target: + entity: + domain: climate + integration: eurotronic_cometblue + fields: + from: + example: 2023-12-24 17:00:00 + required: true + selector: + datetime: + to: + example: 2023-12-31 23:30:00 + required: true + selector: + datetime: + temperature: + example: 20 + required: true + selector: + number: + min: 8 + max: 28 + step: 0.5 + unit_of_measurement: °C diff --git a/homeassistant/components/eurotronic_cometblue/strings.json b/homeassistant/components/eurotronic_cometblue/strings.json index 9722914a088b..e54e9b49a4c1 100644 --- a/homeassistant/components/eurotronic_cometblue/strings.json +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -47,5 +47,63 @@ "name": "Setpoint offset" } } + }, + "services": { + "get_schedule": { + "description": "Retrieves the configured heating time ranges of one or multiple devices.", + "name": "Get schedule" + }, + "set_holiday": { + "description": "Set holiday/away mode on device.", + "fields": { + "from": { + "description": "Start of the away mode.", + "name": "From" + }, + "temperature": { + "description": "Temperature during away mode.", + "name": "Temperature" + }, + "to": { + "description": "End of the away mode.", + "name": "To" + } + }, + "name": "Set holiday (away mode)" + }, + "set_schedule": { + "description": "Sets the configured heating time ranges of one or multiple devices. Days not included in the service call will be left unchanged.", + "fields": { + "friday": { + "description": "Heating time periods for Friday. Up to 4 heat times can be set.", + "name": "[%key:common::time::friday%]" + }, + "monday": { + "description": "Heating time periods for Monday. Up to 4 heat times can be set.", + "name": "[%key:common::time::monday%]" + }, + "saturday": { + "description": "Heating time periods for Saturday. Up to 4 heat times can be set.", + "name": "[%key:common::time::saturday%]" + }, + "sunday": { + "description": "Heating time periods for Sunday. Up to 4 heat times can be set.", + "name": "[%key:common::time::sunday%]" + }, + "thursday": { + "description": "Heating time periods for Thursday. Up to 4 heat times can be set.", + "name": "[%key:common::time::thursday%]" + }, + "tuesday": { + "description": "Heating time periods for Tuesday. Up to 4 heat times can be set.", + "name": "[%key:common::time::tuesday%]" + }, + "wednesday": { + "description": "Heating time periods for Wednesday. Up to 4 heat times can be set.", + "name": "[%key:common::time::wednesday%]" + } + }, + "name": "Set schedule" + } } } diff --git a/tests/components/eurotronic_cometblue/snapshots/test_services.ambr b/tests/components/eurotronic_cometblue/snapshots/test_services.ambr new file mode 100644 index 000000000000..ce635c3ed0e8 --- /dev/null +++ b/tests/components/eurotronic_cometblue/snapshots/test_services.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_get_schedule + dict({ + 'climate.comet_blue_aa_bb_cc_dd_ee_ff': dict({ + 'friday': list([ + dict({ + 'from': '00:00', + 'to': '00:10', + }), + dict({ + 'from': '01:40', + 'to': '03:20', + }), + dict({ + 'from': '01:40', + 'to': '03:20', + }), + dict({ + 'from': '03:30', + 'to': '21:40', + }), + ]), + 'monday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'saturday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'sunday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'thursday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'tuesday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'wednesday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + }), + }) +# --- +# name: test_set_schedule[changed] + dict({ + 'climate.comet_blue_aa_bb_cc_dd_ee_ff': dict({ + 'friday': list([ + dict({ + 'from': '00:00', + 'to': '00:10', + }), + dict({ + 'from': '01:40', + 'to': '03:20', + }), + dict({ + 'from': '01:40', + 'to': '03:20', + }), + dict({ + 'from': '03:30', + 'to': '21:40', + }), + ]), + 'monday': list([ + dict({ + 'from': '08:00', + 'to': '17:00', + }), + ]), + 'saturday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'sunday': list([ + dict({ + 'from': '09:00', + 'to': '11:30', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '18:00', + 'to': '22:00', + }), + ]), + 'thursday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'tuesday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'wednesday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + }), + }) +# --- +# name: test_set_schedule[deleted] + dict({ + 'climate.comet_blue_aa_bb_cc_dd_ee_ff': dict({ + 'saturday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'sunday': list([ + dict({ + 'from': '09:00', + 'to': '11:30', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '18:00', + 'to': '22:00', + }), + ]), + 'thursday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'tuesday': list([ + dict({ + 'from': '09:00', + 'to': '11:30', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '18:00', + 'to': '22:00', + }), + ]), + 'wednesday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + }), + }) +# --- +# name: test_set_schedule[sorted] + dict({ + 'climate.comet_blue_aa_bb_cc_dd_ee_ff': dict({ + 'friday': list([ + dict({ + 'from': '00:00', + 'to': '00:10', + }), + dict({ + 'from': '01:40', + 'to': '03:20', + }), + dict({ + 'from': '01:40', + 'to': '03:20', + }), + dict({ + 'from': '03:30', + 'to': '21:40', + }), + ]), + 'monday': list([ + dict({ + 'from': '08:00', + 'to': '17:00', + }), + ]), + 'saturday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'sunday': list([ + dict({ + 'from': '09:00', + 'to': '11:30', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '18:00', + 'to': '22:00', + }), + ]), + 'thursday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + 'tuesday': list([ + dict({ + 'from': '09:00', + 'to': '11:30', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '13:00', + 'to': '15:00', + }), + dict({ + 'from': '18:00', + 'to': '22:00', + }), + ]), + 'wednesday': list([ + dict({ + 'from': '06:10', + 'to': '22:50', + }), + ]), + }), + }) +# --- diff --git a/tests/components/eurotronic_cometblue/test_services.py b/tests/components/eurotronic_cometblue/test_services.py new file mode 100644 index 000000000000..ad89020371b9 --- /dev/null +++ b/tests/components/eurotronic_cometblue/test_services.py @@ -0,0 +1,286 @@ +"""Test eurotronic_cometblue services.""" + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.eurotronic_cometblue import DOMAIN +from homeassistant.components.number import ServiceValidationError +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .conftest import setup_with_selected_platforms + +from tests.common import MockConfigEntry + +ENTITY_ID = "climate.comet_blue_aa_bb_cc_dd_ee_ff" + + +async def test_get_schedule( + hass: HomeAssistant, + # entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test getting the schedule.""" + await setup_with_selected_platforms(hass, mock_config_entry) + + schedule = await hass.services.async_call( + DOMAIN, + "get_schedule", + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + + snapshot.assert_match(schedule) + + +async def test_set_schedule( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the schedule.""" + await setup_with_selected_platforms(hass, mock_config_entry) + + # Only changed days should be updated, the rest remains the same. + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [{"from": "08:00", "to": "17:00"}], + "sunday": [ + {"from": "09:00", "to": "11:30"}, + {"from": "13:00", "to": "15:00"}, + {"from": "18:00", "to": "22:00"}, + {"from": "23:00", "to": "23:40"}, + ], + }, + blocking=True, + ) + schedule = await hass.services.async_call( + DOMAIN, + "get_schedule", + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert schedule == snapshot(name="changed") + + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "tuesday": [ + {"from": "18:00", "to": "22:00", "data": "ignored"}, + {"from": "09:00", "to": "11:30"}, + {"from": "23:00", "to": "23:40"}, + {"from": "13:00", "to": "15:00"}, + ], + }, + blocking=True, + ) + schedule = await hass.services.async_call( + DOMAIN, + "get_schedule", + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert schedule == snapshot(name="sorted") + + # Test deleting schedule from device + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [], + "friday": [], + }, + blocking=True, + ) + schedule = await hass.services.async_call( + DOMAIN, + "get_schedule", + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert schedule == snapshot(name="deleted") + + +async def test_set_schedule_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_schedule service error handling.""" + await setup_with_selected_platforms(hass, mock_config_entry) + + # voloptuous schema should catch invalid time formats and incorrect data + with pytest.raises(vol.Invalid, match="Invalid time specified"): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [{"from": "08:00", "to": "24:01"}], + }, + blocking=True, + ) + + with pytest.raises(vol.Invalid, match="expected a list for dictionary value"): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": {"from": "08:00", "to": "24:01"}, + }, + blocking=True, + ) + + with pytest.raises(vol.Invalid, match="expected a list for dictionary value"): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": "08:00-17:00", + }, + blocking=True, + ) + + with pytest.raises(vol.Invalid, match="length of value must be at most 4"): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [ + {"from": "08:00", "to": "10:00"}, + {"from": "10:00", "to": "12:00"}, + {"from": "12:00", "to": "14:00"}, + {"from": "14:00", "to": "16:00"}, + {"from": "16:00", "to": "18:00"}, + ], + }, + blocking=True, + ) + + # Errors not caught by voluptous schema + with pytest.raises(ServiceValidationError, match="Missing from/to in entry"): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [{"from": "08:00"}], + }, + blocking=True, + ) + + with pytest.raises( + ServiceValidationError, match="Overlapping times found in schedule" + ): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [ + {"from": "12:00", "to": "14:00"}, + {"from": "10:00", "to": "16:00"}, + ], + }, + blocking=True, + ) + + with pytest.raises(ServiceValidationError, match="Invalid time range"): + await hass.services.async_call( + DOMAIN, + "set_schedule", + { + "entity_id": ENTITY_ID, + "monday": [{"from": "08:00", "to": "07:00"}], + }, + blocking=True, + ) + + +@freeze_time("2026-04-01T18:03:00+00:00") +async def test_set_holiday( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_holiday service.""" + await setup_with_selected_platforms(hass, mock_config_entry) + + # Only changed days should be updated, the rest remains the same. + await hass.services.async_call( + DOMAIN, + "set_holiday", + { + "entity_id": ENTITY_ID, + "from": "2026-04-01 19:00:00", + "to": "2026-04-10 12:30:00", + "temperature": 21.5, + }, + blocking=True, + ) + await mock_config_entry.runtime_data.async_refresh() + + # Testing against device data as holiday is not directly exposed as entity state + # Datetime is also floored to hours in local time + assert mock_config_entry.runtime_data.data.holiday == { + "start": dt_util.dt.datetime(2026, 4, 1, 19, 0, 0), + "end": dt_util.dt.datetime(2026, 4, 10, 12, 0, 0), + "temperature": 21.5, + } + + +@freeze_time("2026-04-01T18:03:00+00:00") +async def test_set_holiday_errors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_holiday service error handling.""" + await setup_with_selected_platforms(hass, mock_config_entry) + + # Start date must be in the future (at least 1 hour ahead as time is floored to hours on device) + with pytest.raises( + ServiceValidationError, match="Start date .* must be in the future" + ): + await hass.services.async_call( + DOMAIN, + "set_holiday", + { + "entity_id": ENTITY_ID, + "from": "2026-04-01 17:50:00", + "to": "2026-04-10 12:30:00", + "temperature": 21.5, + }, + blocking=True, + ) + + # Temperature must be a half precision float + with pytest.raises( + ServiceValidationError, match="value .* is not a half precision float" + ): + await hass.services.async_call( + DOMAIN, + "set_holiday", + { + "entity_id": ENTITY_ID, + "from": "2026-04-01 19:00:00", + "to": "2026-04-10 12:30:00", + "temperature": 21.3, + }, + blocking=True, + )