mirror of
https://github.com/home-assistant/core.git
synced 2026-09-07 22:20:07 +01:00
419 lines
14 KiB
Python
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,
|
|
)
|