diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 6e89fd2c9f7..818c4e6fe19 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, @@ -39,6 +40,9 @@ from .const import ( SERVICE_SELECT_PROGRAM, SERVICE_SETTING, SERVICE_START_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_KEY, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_VALUE, ) type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] @@ -139,6 +143,43 @@ def _get_appliance( raise ValueError(f"Appliance for device id {device_entry.id} not found") +def _get_appliance_or_raise_service_validation_error( + hass: HomeAssistant, device_id: str +) -> api.HomeConnectAppliance: + """Return a Home Connect appliance instance or raise a service validation error.""" + try: + return _get_appliance(hass, device_id) + except (ValueError, AssertionError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) from err + + +async def _run_appliance_service[*_Ts]( + hass: HomeAssistant, + appliance: api.HomeConnectAppliance, + method: str, + *args: *_Ts, + error_translation_key: str, + error_translation_placeholders: dict[str, str], +) -> None: + try: + await hass.async_add_executor_job(getattr(appliance, method), args) + except api.HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **error_translation_placeholders, + }, + ) from err + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" @@ -158,16 +199,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: option[ATTR_UNIT] = option_unit options.append(option) - - appliance = _get_appliance(hass, device_id) - await hass.async_add_executor_job(getattr(appliance, method), program, options) + await _run_appliance_service( + hass, + _get_appliance_or_raise_service_validation_error(hass, device_id), + method, + program, + options, + error_translation_key=method, + error_translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) async def _async_service_command(call, command): """Execute calls to services executing a command.""" device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance(hass, device_id) - await hass.async_add_executor_job(appliance.execute_command, command) + appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) + await _run_appliance_service( + hass, + appliance, + "execute_command", + command, + error_translation_key="execute_command", + error_translation_placeholders={"command": command}, + ) async def _async_service_key_value(call, method): """Execute calls to services taking a key and value.""" @@ -176,20 +232,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: unit = call.data.get(ATTR_UNIT) device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance(hass, device_id) - if unit is not None: - await hass.async_add_executor_job( - getattr(appliance, method), - key, - value, - unit, - ) - else: - await hass.async_add_executor_job( - getattr(appliance, method), - key, - value, - ) + await _run_appliance_service( + hass, + _get_appliance_or_raise_service_validation_error(hass, device_id), + method, + *((key, value) if unit is None else (key, value, unit)), + error_translation_key=method, + error_translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) async def async_service_option_active(call): """Service for setting an option for an active program.""" diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e9f32b0e772..e20cf3b1fa0 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -127,9 +127,12 @@ ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" -SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY = "setting_key" +SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" +SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index fc53939b9d8..0703b4772bb 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -22,8 +22,9 @@ from .const import ( ATTR_UNIT, ATTR_VALUE, DOMAIN, + SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectEntity @@ -119,11 +120,11 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_setting", + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), }, ) from err diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 46b2bda24d6..c97b3db28e0 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -22,6 +22,7 @@ from .const import ( BSH_ACTIVE_PROGRAM, BSH_SELECTED_PROGRAM, DOMAIN, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, ) from .entity import HomeConnectEntity @@ -294,7 +295,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, }, ) from err self.async_entity_update() diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5f5ed3cee54..e70f2f28c65 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -22,6 +22,9 @@ } }, "exceptions": { + "appliance_not_found": { + "message": "Appliance for device id {device_id} not found" + }, "turn_on_light": { "message": "Error turning on {entity_id}: {description}" }, @@ -37,14 +40,17 @@ "set_light_color": { "message": "Error setting color of {entity_id}: {description}" }, + "set_setting_entity": { + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{setting_key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" }, "turn_on": { - "message": "Error turning on {entity_id} ({setting_key}): {description}" + "message": "Error turning on {entity_id} ({key}): {description}" }, "turn_off": { - "message": "Error turning off {entity_id} ({setting_key}): {description}" + "message": "Error turning off {entity_id} ({key}): {description}" }, "select_program": { "message": "Error selecting program {program}: {description}" @@ -52,8 +58,20 @@ "start_program": { "message": "Error starting program {program}: {description}" }, + "pause_program": { + "message": "Error pausing program: {description}" + }, "stop_program": { - "message": "Error stopping program {program}: {description}" + "message": "Error stopping program: {description}" + }, + "set_options_active_program": { + "message": "Error setting options for the active program: {description}" + }, + "set_options_selected_program": { + "message": "Error setting options for the selected program: {description}" + }, + "execute_command": { + "message": "Error executing command {command}: {description}" }, "power_on": { "message": "Error turning on {appliance_name}: {description}" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 7e3a285912b..acb78e87db1 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -30,7 +30,7 @@ from .const import ( REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectDevice, HomeConnectEntity @@ -140,7 +140,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, }, ) from err @@ -164,7 +164,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, }, ) from err @@ -230,7 +230,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="stop_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, }, ) from err self.async_entity_update() diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index cad16d63cb2..c1f125cd2f7 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -14,8 +14,9 @@ from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( ATTR_VALUE, DOMAIN, + SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectEntity @@ -82,11 +83,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_setting", + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), }, ) from err diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index d2eff43e071..2ac8c851e1b 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -183,10 +183,15 @@ def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: mock.get_programs_available.side_effect = HomeConnectError mock.start_program.side_effect = HomeConnectError mock.select_program.side_effect = HomeConnectError + mock.pause_program.side_effect = HomeConnectError mock.stop_program.side_effect = HomeConnectError + mock.set_options_active_program.side_effect = HomeConnectError + mock.set_options_selected_program.side_effect = HomeConnectError mock.get_status.side_effect = HomeConnectError mock.get_settings.side_effect = HomeConnectError mock.set_setting.side_effect = HomeConnectError + mock.set_setting.side_effect = HomeConnectError + mock.execute_command.side_effect = HomeConnectError return mock diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 7c4f73b6f0a..69601efb42d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -29,6 +29,7 @@ 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.helpers import device_registry as dr, entity_registry as er from script.hassfest.translations import RE_TRANSLATION_KEY @@ -290,8 +291,40 @@ async def test_services( ) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) @pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( + service_call: list[dict[str, Any]], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + problematic_appliance: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + get_appliances.return_value = [problematic_appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, problematic_appliance.haId)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +@pytest.mark.usefixtures("bypass_throttle") +async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[], Awaitable[bool]], @@ -299,7 +332,7 @@ async def test_services_exception( get_appliances: MagicMock, appliance: Mock, ) -> None: - """Raise a ValueError when device id does not match.""" + """Raise a ServiceValidationError when device id does not match.""" get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup() @@ -309,7 +342,7 @@ async def test_services_exception( service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - with pytest.raises(AssertionError): + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call)