mirror of
https://github.com/home-assistant/core.git
synced 2026-08-06 21:35:13 +01:00
Add entity actions to Eurotronic Cometblue (#170689)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user