diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index d3f2eb45d681d3..3947ec6b7fb38a 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -41,6 +41,7 @@ json_dumps, ) from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import EventType from homeassistant.loader import ( Integration, @@ -58,6 +59,8 @@ ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +DATA_EXCEPTION_TRANSLATIONS = "websocket_api_exceptions_translations" + @callback def async_register_commands( @@ -240,7 +243,8 @@ async def handle_call_service( except ServiceValidationError as err: connection.logger.error(err) connection.logger.debug("", exc_info=err) - connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err)) + message = await async_build_error_message(hass, err) + connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, message) except HomeAssistantError as err: connection.logger.exception(err) connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err)) @@ -249,6 +253,43 @@ async def handle_call_service( connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err)) +async def async_get_exception_translations( + hass: HomeAssistant, domain: str +) -> dict[str, Any]: + """Get translations for exceptions for a domain.""" + return await async_get_translations( + hass, hass.config.language, "exceptions", {domain} + ) + + +async def async_build_error_message( + hass: HomeAssistant, err: ServiceValidationError +) -> str: + """Build translated error message from exception.""" + if (translation_key := err.translation_key) is None or ( + domain := err.domain + ) is None: + return str(err) + + exception_translations: dict[str, dict[str, Any]] = hass.data.setdefault( + DATA_EXCEPTION_TRANSLATIONS, {} + ) + if (domain_exception_translations := exception_translations.get(domain)) is None: + domain_exception_translations = await async_get_exception_translations( + hass, domain + ) + exception_translations[domain] = domain_exception_translations + exception_message: str | None + if ( + exception_message := domain_exception_translations.get( + f"component.{domain}.exceptions.{translation_key}.message", + ) + ) is None: + return str(err) + place_holders = err.translation_placeholders or {} + return exception_message.format(**place_holders, message=str(err)) # type: ignore[no-any-return] + + @callback def _async_get_allowed_states( hass: HomeAssistant, connection: ActiveConnection diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 1915d34b3e7797..b37327a248f830 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -16,6 +16,19 @@ class HomeAssistantError(Exception): class ServiceValidationError(HomeAssistantError): """A validation exception occurred when calling a service.""" + def __init__( + self, + *args: object, + domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + """Initialize exception.""" + super().__init__(*args) + self.domain = domain + self.translation_key = translation_key + self.translation_placeholders = translation_placeholders + class InvalidEntityFormatError(HomeAssistantError): """When an invalid formatted entity is encountered.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c164e3b1052abb..7d390390e2d917 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -123,6 +123,7 @@ def __init__( self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} self.component_translations: dict[str, Any] = {} + self.exception_translations: dict[str, Any] = {} self.platform_translations: dict[str, Any] = {} self.object_id_component_translations: dict[str, Any] = {} self.object_id_platform_translations: dict[str, Any] = {} @@ -328,6 +329,9 @@ async def get_translations( self.component_translations = await get_translations( hass.config.language, "entity_component", self.domain ) + self.exception_translations = await get_translations( + hass.config.language, "exceptions", self.domain + ) self.platform_translations = await get_translations( hass.config.language, "entity", self.platform_name ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5c6d7b19719ce4..9c96f234893db8 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -327,6 +327,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), slug_validator=cv.slug, ), + vol.Optional("exceptions"): cv.schema_with_slug_keys( + {vol.Optional("message"): translation_value_validator}, + slug_validator=cv.slug, + ), vol.Optional("services"): cv.schema_with_slug_keys( { vol.Required("name"): translation_value_validator, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a189eae3d789bf..7ab8c7bb4c2c63 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -455,10 +455,12 @@ def ha_error_call(_): hass.services.async_register("domain_test", "ha_error", ha_error_call) @callback - def service_error_call(_): + def service_validation_error_call(_): raise ServiceValidationError("error_message") - hass.services.async_register("domain_test", "service_error", service_error_call) + hass.services.async_register( + "domain_test", "service_validation_error", service_validation_error_call + ) async def unknown_error_call(_): raise ValueError("value_error") @@ -486,33 +488,131 @@ async def unknown_error_call(_): "id": 6, "type": "call_service", "domain": "domain_test", - "service": "service_error", - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] is False - assert msg["error"]["code"] == "home_assistant_error" - assert msg["error"]["message"] == "error_message" - - await websocket_client.send_json( - { - "id": 7, - "type": "call_service", - "domain": "domain_test", "service": "unknown_error", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" assert msg["error"]["message"] == "value_error" + error_strings = {} + with patch( + "homeassistant.components.websocket_api.commands.async_get_translations", + return_value=error_strings, + ) as mock_get_translations: + await websocket_client.send_json( + { + "id": 7, + "type": "call_service", + "domain": "domain_test", + "service": "service_validation_error", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == "error_message" + assert len(mock_get_translations.mock_calls) == 0 + + +@pytest.mark.parametrize( + ( + "domain", + "translation_key", + "translation_placeholders", + "translations", + "message", + ), + [ + ( + "test", + "custom_error", + {"option": "beer"}, + { + "component.test.exceptions.custom_error.message": "Translated error {option}: {message}" + }, + "Translated error beer: error_message", + ), + ( + "test", + "custom_error", + None, + { + "component.test.exceptions.custom_error.message": "Translated error: {message}" + }, + "Translated error: error_message", + ), + ("test", "custom_error_key_not_exists", None, {}, "error_message"), + ], +) +async def test_exception_translations( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + domain: str | None, + translation_key: str | None, + translations: dict[str, str], + translation_placeholders: dict[str, str] | None, + message: str, +) -> None: + """Test handling exceptions with translations.""" + + @callback + def service_error_call(_): + raise ServiceValidationError( + "error_message", + domain=domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + hass.services.async_register(domain, "service_validation_error", service_error_call) + + with patch( + "homeassistant.components.websocket_api.commands.async_get_translations", + return_value=translations, + ) as mock_get_translations: + await websocket_client.send_json( + { + "id": 7, + "type": "call_service", + "domain": domain, + "service": "service_validation_error", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == message + assert len(mock_get_translations.mock_calls) == 1 + + # retry using cache + await websocket_client.send_json( + { + "id": 8, + "type": "call_service", + "domain": domain, + "service": "service_validation_error", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 8 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == message + assert len(mock_get_translations.mock_calls) == 1 + async def test_subscribe_unsubscribe_events( hass: HomeAssistant, websocket_client