Add entity actions to Eurotronic Cometblue (#170689)

This commit is contained in:
Richard Kroegel
2026-07-26 23:13:50 +02:00
committed by GitHub
parent 17e0d1740d
commit 78cfaaa49f
8 changed files with 1131 additions and 11 deletions
@@ -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)
@@ -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"
}
}
}
@@ -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
@@ -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,
)
@@ -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
@@ -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"
}
}
}
@@ -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',
}),
]),
}),
})
# ---
@@ -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,
)