From 9d9b352631b9ed30a179af72c3f272acff4f216a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 12:35:46 +0100 Subject: [PATCH] Move Home Connect service actions to a services.py (#141100) * Move Home Connect service actions to a actions.py * Rename actions.py to services.py * Move more fuctions to module level --- .../components/home_connect/__init__.py | 576 +----------------- .../components/home_connect/services.py | 572 +++++++++++++++++ .../{test_init.ambr => test_services.ambr} | 0 tests/components/home_connect/test_init.py | 456 +------------- .../components/home_connect/test_services.py | 468 ++++++++++++++ 5 files changed, 1052 insertions(+), 1020 deletions(-) create mode 100644 homeassistant/components/home_connect/services.py rename tests/components/home_connect/snapshots/{test_init.ambr => test_services.ambr} (100%) create mode 100644 tests/components/home_connect/test_services.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 83de76431f9..fe01a3e9564 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,192 +2,29 @@ from __future__ import annotations -from collections.abc import Awaitable import logging -from typing import Any, cast +from typing import Any from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import ( - ArrayOfOptions, - CommandKey, - Option, - OptionKey, - ProgramKey, - SettingKey, -) -from aiohomeconnect.model.error import HomeConnectError import aiohttp -import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - device_registry as dr, -) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth -from .const import ( - AFFECTS_TO_ACTIVE_PROGRAM, - AFFECTS_TO_SELECTED_PROGRAM, - ATTR_AFFECTS_TO, - ATTR_KEY, - ATTR_PROGRAM, - ATTR_UNIT, - ATTR_VALUE, - DOMAIN, - OLD_NEW_UNIQUE_ID_SUFFIX_MAP, - PROGRAM_ENUM_OPTIONS, - SERVICE_OPTION_ACTIVE, - SERVICE_OPTION_SELECTED, - SERVICE_PAUSE_PROGRAM, - SERVICE_RESUME_PROGRAM, - SERVICE_SELECT_PROGRAM, - SERVICE_SET_PROGRAM_AND_OPTIONS, - SERVICE_SETTING, - SERVICE_START_PROGRAM, - TRANSLATION_KEYS_PROGRAMS_MAP, -) +from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +from .services import register_actions _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: int, - OptionKey.BSH_COMMON_START_IN_RELATIVE: int, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, - 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.COOKING_OVEN_SETPOINT_TEMPERATURE: int, - OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: 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), - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_OPTION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - vol.Optional(ATTR_UNIT): str, - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_PROGRAM_SCHEMA = vol.Any( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(int, str), - vol.Optional(ATTR_UNIT): str, - }, - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - }, -) - - -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_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -200,402 +37,9 @@ PLATFORMS = [ ] -async def _get_client_and_ha_id( - hass: HomeAssistant, device_id: str -) -> tuple[HomeConnectClient, str]: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - entry: HomeConnectConfigEntry | None = None - for entry_id in device_entry.config_entries: - _entry = hass.config_entries.async_get_entry(entry_id) - assert _entry - if _entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, _entry) - break - if entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - - ha_id = next( - ( - identifier[1] - for identifier in device_entry.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 entry.runtime_data.client, ha_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - - async def _async_service_program(call: ServiceCall, start: bool) -> None: - """Execute calls to services taking a program.""" - program = call.data[ATTR_PROGRAM] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - option_key = call.data.get(ATTR_KEY) - options = ( - [ - Option( - option_key, - call.data[ATTR_VALUE], - unit=call.data.get(ATTR_UNIT), - ) - ] - if option_key is not None - else None - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_PROGRAM}: {program}", - *([f" {ATTR_KEY}: {options[0].key}"] if options else []), - *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), - *( - [f" {ATTR_UNIT}: {options[0].unit}"] - if options and options[0].unit - else [] - ), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", - f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", - *( - [ - f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" - ] - if options - else [] - ), - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - - try: - if start: - await client.start_program(ha_id, program_key=program, options=options) - else: - await client.set_selected_program( - ha_id, program_key=program, options=options - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program" if start else "select_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "program": program, - }, - ) from err - - async def _async_service_set_program_options( - call: ServiceCall, active: bool - ) -> None: - """Execute calls to services taking a program.""" - option_key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - unit = call.data.get(ATTR_UNIT) - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_KEY}: {option_key}", - f" {ATTR_VALUE}: {value}", - *([f" {ATTR_UNIT}: {unit}"] if unit else []), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", - f" {bsh_key_to_translation_key(option_key)}: {value}", - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - try: - if active: - await client.set_active_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - else: - await client.set_selected_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_options_active_program" - if active - else "set_options_selected_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "key": option_key, - "value": str(value), - }, - ) from err - - async def _async_service_command( - call: ServiceCall, command_key: CommandKey - ) -> None: - """Execute calls to services executing a command.""" - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_command_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_command_actions", - ) - - try: - await client.put_command(ha_id, command_key=command_key, value=True) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="execute_command", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "command": command_key.value, - }, - ) from err - - async def async_service_option_active(call: ServiceCall) -> None: - """Service for setting an option for an active program.""" - await _async_service_set_program_options(call, True) - - async def async_service_option_selected(call: ServiceCall) -> None: - """Service for setting an option for a selected program.""" - await _async_service_set_program_options(call, False) - - 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(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 async_service_pause_program(call: ServiceCall) -> None: - """Service for pausing a program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - - async def async_service_resume_program(call: ServiceCall) -> None: - """Service for resuming a paused program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - - async def async_service_select_program(call: ServiceCall) -> None: - """Service for selecting a program.""" - await _async_service_program(call, False) - - 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(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] - ) - - 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: - 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 - 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_program(call: ServiceCall) -> None: - """Service for starting a program.""" - await _async_service_program(call, True) - - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_ACTIVE, - async_service_option_active, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_SELECTED, - async_service_option_selected, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_PROGRAM, - async_service_pause_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - async_service_resume_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_PROGRAM, - async_service_select_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_START_PROGRAM, - async_service_start_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PROGRAM_AND_OPTIONS, - async_service_set_program_and_options, - schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, - ) - + register_actions(hass) return True diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py new file mode 100644 index 00000000000..fac1c5fe1a9 --- /dev/null +++ b/homeassistant/components/home_connect/services.py @@ -0,0 +1,572 @@ +"""Custom actions (previously known as services) for the Home Connect integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, cast + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + DOMAIN, + PROGRAM_ENUM_OPTIONS, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, + SERVICE_SETTING, + SERVICE_START_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from .coordinator import HomeConnectConfigEntry +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +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: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + 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.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: 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), + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + }, +) + + +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_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + + +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + ha_id = next( + ( + identifier[1] + for identifier in device_entry.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 entry.runtime_data.client, ha_id + + +async def _async_service_program(call: ServiceCall, start: bool) -> None: + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + option_key = call.data.get(ATTR_KEY) + options = ( + [ + Option( + option_key, + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None + ) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "program": program, + }, + ) from err + + +async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None: + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": option_key, + "value": str(value), + }, + ) from err + + +async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None: + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + +async def async_service_option_active(call: ServiceCall) -> None: + """Service for setting an option for an active program.""" + await _async_service_set_program_options(call, True) + + +async def async_service_option_selected(call: ServiceCall) -> None: + """Service for setting an option for a selected program.""" + await _async_service_set_program_options(call, False) + + +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 async_service_pause_program(call: ServiceCall) -> None: + """Service for pausing a program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) + + +async def async_service_resume_program(call: ServiceCall) -> None: + """Service for resuming a paused program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) + + +async def async_service_select_program(call: ServiceCall) -> None: + """Service for selecting a program.""" + await _async_service_program(call, False) + + +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] + ) + + 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: + 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 + 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_program(call: ServiceCall) -> None: + """Service for starting a program.""" + await _async_service_program(call, True) + + +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_services.ambr similarity index 100% rename from tests/components/home_connect/snapshots/test_init.ambr rename to tests/components/home_connect/snapshots/test_services.ambr diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 291caeafd58..e0e586929a9 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,12 +1,11 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey +from aiohomeconnect.model import SettingKey, StatusKey from aiohomeconnect.model.error import ( HomeConnectError, TooManyRequestsError, @@ -14,7 +13,6 @@ from aiohomeconnect.model.error import ( ) import aiohttp import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -25,9 +23,8 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -40,157 +37,6 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - -DEPRECATED_SERVICE_KV_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "set_option_active", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_option_selected", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, -] - -SERVICE_KV_CALL_PARAMS = [ - *DEPRECATED_SERVICE_KV_CALL_PARAMS, - { - "domain": DOMAIN, - "service": "change_setting", - "service_data": { - "device_id": "DEVICE_ID", - "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, - "value": True, - }, - "blocking": True, - }, -] - -SERVICE_COMMAND_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "pause_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "resume_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, -] - - -SERVICE_PROGRAM_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "select_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "start_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, -] - -SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_active_program_option", - "set_option_selected": "set_selected_program_option", - "change_setting": "set_setting", - "pause_program": "put_command", - "resume_program": "put_command", - "select_program": "set_selected_program", - "start_program": "start_program", -} - -SERVICE_VALIDATION_ERROR_MAPPING = { - "set_option_active": r"Error.*setting.*options.*active.*program.*", - "set_option_selected": r"Error.*setting.*options.*selected.*program.*", - "change_setting": r"Error.*assigning.*value.*setting.*", - "pause_program": r"Error.*executing.*command.*", - "resume_program": r"Error.*executing.*command.*", - "select_program": r"Error.*selecting.*program.*", - "start_program": r"Error.*starting.*program.*", -} - - -SERVICES_SET_PROGRAM_AND_OPTIONS = [ - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": 1800, - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "program": "consumer_products_coffee_maker_program_beverage_coffee", - "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "consumer_products_coffee_maker_option_fill_quantity": 35, - }, - "blocking": True, - }, -] async def test_entry_setup( @@ -401,197 +247,6 @@ async def test_client_rate_limit_error( asyncio_sleep_mock.assert_called_once_with(retry_after) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_key_value_services( - service_call: dict[str, Any], - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, -) -> None: - """Create and test services.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_name = service_call["service"] - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - assert ( - getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 - ) - - -@pytest.mark.parametrize( - ("service_call", "issue_id"), - [ - *zip( - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, - ["deprecated_set_program_and_option_actions"] - * ( - len(DEPRECATED_SERVICE_KV_CALL_PARAMS) - + len(SERVICE_PROGRAM_CALL_PARAMS) - ), - strict=True, - ), - *zip( - SERVICE_COMMAND_CALL_PARAMS, - ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), - strict=True, - ), - ], -) -async def test_programs_and_options_actions_deprecation( - service_call: dict[str, Any], - issue_id: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test deprecated service keys.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.parametrize( - ("service_call", "called_method"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - "set_selected_program", - "start_program", - "set_active_program_options", - "set_selected_program_options", - ], - strict=True, - ), -) -async def test_set_program_and_options( - service_call: dict[str, Any], - called_method: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - snapshot: SnapshotAssertion, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - method_mock: MagicMock = getattr(client, called_method) - assert method_mock.call_count == 1 - assert method_mock.call_args == snapshot - - -@pytest.mark.parametrize( - ("service_call", "error_regex"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - r"Error.*selecting.*program.*", - r"Error.*starting.*program.*", - r"Error.*setting.*options.*active.*program.*", - r"Error.*setting.*options.*selected.*program.*", - ], - strict=True, - ), -) -async def test_set_program_and_options_exceptions( - service_call: dict[str, Any], - error_regex: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises(HomeAssistantError, match=error_regex): - await hass.services.async_call(**service_call) - - async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -626,113 +281,6 @@ async def test_required_program_or_at_least_an_option( ) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception_device_id( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a HomeAssistantError when there is an API error.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(HomeAssistantError): - await hass.services.async_call(**service_call) - - -async def test_services_appliance_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ServiceValidationError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - service_call = SERVICE_KV_CALL_PARAMS[0] - - service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - - with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): - await hass.services.async_call(**service_call) - - unrelated_config_entry = MockConfigEntry( - domain="TEST", - ) - unrelated_config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=unrelated_config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): - await hass.services.async_call(**service_call) - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): - await hass.services.async_call(**service_call) - - -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ValueError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - service_name = service_call["service"] - with pytest.raises( - HomeAssistantError, - match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], - ): - await hass.services.async_call(**service_call) - - async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py new file mode 100644 index 00000000000..517564724a9 --- /dev/null +++ b/tests/components/home_connect/test_services.py @@ -0,0 +1,468 @@ +"""Tests for the Home Connect actions.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "set_option_active", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_option_selected", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, + { + "domain": DOMAIN, + "service": "change_setting", + "service_data": { + "device_id": "DEVICE_ID", + "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, + "value": True, + }, + "blocking": True, + }, +] + +SERVICE_COMMAND_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "pause_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "resume_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, +] + + +SERVICE_PROGRAM_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "select_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "start_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, +] + +SERVICE_APPLIANCE_METHOD_MAPPING = { + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", + "change_setting": "set_setting", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", + "start_program": "start_program", +} + +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": 1800, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_key_value_services( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Create and test services.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_name = service_call["service"] + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + assert ( + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 + ) + + +@pytest.mark.parametrize( + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + issue_id: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception_device_id( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +async def test_services_appliance_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ServiceValidationError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + service_call = SERVICE_KV_CALL_PARAMS[0] + + service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call)