Add exception handlers to Home Connect action calls (#131895)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
J. Diego Rodríguez Royo 2024-12-06 22:58:13 +01:00 committed by GitHub
parent 18e8b080e0
commit 0d0ef6bf03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 151 additions and 37 deletions

View File

@ -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."""

View File

@ -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 = {

View File

@ -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

View File

@ -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()

View File

@ -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}"

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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)