Files
core/homeassistant/components/home_connect/services.py
T

419 lines
14 KiB
Python

"""Custom actions (previously known as services) for the Home Connect integration."""
from collections.abc import Awaitable, Callable
from functools import partial
import logging
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
ArrayOfOptions,
Option,
OptionKey,
ProgramKey,
SettingKey,
)
from aiohomeconnect.model.error import HomeConnectError, NoProgramActiveError
from aiohomeconnect.model.program import Program, ProgramDefinition
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import (
AFFECTS_TO_ACTIVE_PROGRAM,
AFFECTS_TO_SELECTED_PROGRAM,
ATTR_AFFECTS_TO,
ATTR_KEY,
ATTR_PROGRAM,
ATTR_VALUE,
DOMAIN,
PROGRAM_ENUM_OPTIONS,
SERVICE_SET_PROGRAM_AND_OPTIONS,
SERVICE_SETTING,
SERVICE_START_SELECTED_PROGRAM,
TRANSLATION_KEYS_PROGRAMS_MAP,
)
from .coordinator import HomeConnectConfigEntry
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PROGRAM_OPTIONS = {
bsh_key_to_translation_key(key): (
key,
value,
)
for key, value in {
OptionKey.BSH_COMMON_DURATION: vol.All(int, vol.Range(min=0)),
OptionKey.BSH_COMMON_START_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: vol.All(
int, vol.Range(min=0)
),
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool,
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool,
OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
): vol.All(int, vol.Range(min=1, max=100)),
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE: vol.Coerce(
float
),
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool,
OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool,
OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool,
OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool,
OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool,
OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool,
OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool,
}.items()
}
SERVICE_SETTING_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(SettingKey),
vol.NotIn([SettingKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
}
)
TEMPERATURE_OPTIONS = {
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_SETPOINT_TEMPERATURE,
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE,
}
def _require_program_or_at_least_one_option(data: dict) -> dict:
if ATTR_PROGRAM not in data and not any(
option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="required_program_or_one_option_at_least",
)
return data
SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_AFFECTS_TO): vol.In(
[AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM]
),
vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()),
}
)
.extend(
{
vol.Optional(translation_key): vol.In(allowed_values.keys())
for translation_key, (
key,
allowed_values,
) in PROGRAM_ENUM_OPTIONS.items()
}
)
.extend(
{
vol.Optional(translation_key): schema
for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
}
),
_require_program_or_at_least_one_option,
)
SERVICE_START_SELECTED_PROGRAM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
}
).extend(
{
vol.Optional(translation_key): schema
for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
if key
in (
OptionKey.BSH_COMMON_START_IN_RELATIVE,
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
)
}
)
)
async def _get_client_and_ha_id(
hass: HomeAssistant, device_id: str
) -> tuple[HomeConnectClient, str]:
config_entry: HomeConnectConfigEntry
device, config_entry = service.async_get_device_and_config_entry(
hass, DOMAIN, device_id
)
ha_id = next(
(identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN),
None,
)
if ha_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="appliance_not_found",
translation_placeholders={
"device_id": device_id,
},
)
return config_entry.runtime_data.client, ha_id
async def async_service_setting(call: ServiceCall) -> None:
"""Service for changing a setting."""
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
try:
await client.set_setting(ha_id, setting_key=key, value=value)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_setting",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"key": key,
"value": str(value),
},
) from err
async def _check_temperature_options(
options: list[Option],
method_call: Callable[..., Awaitable[Program | ProgramDefinition]],
) -> None:
if not options or not (
options_to_check := {
option.key: option
for option in options
if option.key in TEMPERATURE_OPTIONS
}
):
return
try:
program_data = await method_call()
except HomeConnectError:
LOGGER.debug("Failed to get information about temperature options, using °C")
else:
checked_options = []
for option in program_data.options or []:
if _option := options_to_check.get(option.key):
checked_options.append(option.key)
if option.unit == "°F":
_option.value = TemperatureConverter.convert(
_option.value,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
if set(checked_options) != options_to_check.keys():
LOGGER.debug(
"Couldn't check all the temperature options units,"
" using °C for the ones that couldn't be checked"
)
async def async_service_set_program_and_options(call: ServiceCall) -> None:
"""Service for setting a program and options."""
data = dict(call.data)
program = data.pop(ATTR_PROGRAM, None)
affects_to = data.pop(ATTR_AFFECTS_TO)
client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID))
options: list[Option] = []
for option, value in data.items():
if option in PROGRAM_ENUM_OPTIONS:
options.append(
Option(
PROGRAM_ENUM_OPTIONS[option][0],
PROGRAM_ENUM_OPTIONS[option][1][value],
)
)
elif option in PROGRAM_OPTIONS:
option_key = PROGRAM_OPTIONS[option][0]
options.append(Option(option_key, value))
method_call: Awaitable[Any]
exception_translation_key: str
if program:
program = (
program
if isinstance(program, ProgramKey)
else TRANSLATION_KEYS_PROGRAMS_MAP[program]
)
await _check_temperature_options(
options,
partial(client.get_available_program, ha_id, program_key=program),
)
if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
method_call = client.start_program(
ha_id, program_key=program, options=options
)
exception_translation_key = "start_program"
elif affects_to == AFFECTS_TO_SELECTED_PROGRAM:
method_call = client.set_selected_program(
ha_id, program_key=program, options=options
)
exception_translation_key = "select_program"
else:
array_of_options = ArrayOfOptions(options)
if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
await _check_temperature_options(
options, partial(client.get_active_program, ha_id)
)
method_call = client.set_active_program_options(
ha_id, array_of_options=array_of_options
)
exception_translation_key = "set_options_active_program"
else:
# affects_to is AFFECTS_TO_SELECTED_PROGRAM
await _check_temperature_options(
options, partial(client.get_selected_program, ha_id)
)
method_call = client.set_selected_program_options(
ha_id, array_of_options=array_of_options
)
exception_translation_key = "set_options_selected_program"
try:
await method_call
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=exception_translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
**({"program": program} if program else {}),
},
) from err
async def async_service_start_selected_program(call: ServiceCall) -> None:
"""Service to start a program that is already selected."""
data = dict(call.data)
client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID))
try:
try:
program_obj = await client.get_active_program(ha_id)
except NoProgramActiveError:
program_obj = await client.get_selected_program(ha_id)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="fetch_program_error",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
if not program_obj.key:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_program_to_start",
)
program = program_obj.key
# Fetch the writable option schema for this program on this appliance.
# Only options present in this response are accepted by PUT /programs/active.
try:
available_program = await client.get_available_program(
ha_id, program_key=program
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="fetch_program_error",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
writable_keys = {opt.key for opt in available_program.options or []}
# Start from the values reported by selected/active, but keep only those
# that are actually writable for this (appliance, program) pair.
options_dict = {
option.key: option
for option in program_obj.options or []
if option.key in writable_keys
}
# User-provided overrides from the service call. Validate against the
# writable schema so misconfigurations fail fast with a clear message
# instead of being rejected by the API.
for option, value in data.items():
option_key = PROGRAM_OPTIONS[option][0]
if option_key not in writable_keys:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program_option_not_writable",
translation_placeholders={
"option": option_key.value,
"program": program.value,
},
)
options_dict[option_key] = Option(option_key, value)
try:
await client.start_program(
ha_id,
program_key=program,
options=list(options_dict.values()) if options_dict else None,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program",
translation_placeholders={
"program": program,
**get_dict_from_home_connect_error(err),
},
) from err
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register custom actions."""
hass.services.async_register(
DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_PROGRAM_AND_OPTIONS,
async_service_set_program_and_options,
schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_START_SELECTED_PROGRAM,
async_service_start_selected_program,
schema=SERVICE_START_SELECTED_PROGRAM_SCHEMA,
)