diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index c1a4e67ffd4..e0ae2b52fa0 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -31,6 +31,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, exception_wrap @@ -218,8 +219,13 @@ class RingCam(RingEntity[RingDoorBell], Camera): ) -> None: """Handle a WebRTC candidate.""" if candidate.sdp_m_line_index is None: - msg = "The sdp_m_line_index is required for ring webrtc streaming" - raise HomeAssistantError(msg) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sdp_m_line_index_required", + translation_placeholders={ + "device": self._device.name, + }, + ) await self._device.on_webrtc_candidate( session_id, candidate.candidate, candidate.sdp_m_line_index ) diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index f35a6e10b9f..413c48c35eb 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -27,7 +27,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -45,26 +45,6 @@ class RingData: type RingConfigEntry = ConfigEntry[RingData] -async def _call_api[*_Ts, _R]( - hass: HomeAssistant, - target: Callable[[*_Ts], Coroutine[Any, Any, _R]], - *args: *_Ts, - msg_suffix: str = "", -) -> _R: - try: - return await target(*args) - except AuthenticationError as err: - # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) - raise ConfigEntryAuthFailed from err - except RingTimeout as err: - raise UpdateFailed( - f"Timeout communicating with API{msg_suffix}: {err}" - ) from err - except RingError as err: - raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err - - class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): """Base class for device coordinators.""" @@ -87,12 +67,37 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): self.ring_api: Ring = ring_api self.first_call: bool = True + async def _call_api[*_Ts, _R]( + self, + target: Callable[[*_Ts], Coroutine[Any, Any, _R]], + *args: *_Ts, + ) -> _R: + try: + return await target(*args) + except AuthenticationError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication", + ) from err + except RingTimeout as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_timeout", + ) from err + except RingError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + async def _async_update_data(self) -> RingDevices: """Fetch data from API endpoint.""" update_method: str = ( "async_update_data" if self.first_call else "async_update_devices" ) - await _call_api(self.hass, getattr(self.ring_api, update_method)) + await self._call_api(getattr(self.ring_api, update_method)) self.first_call = False devices: RingDevices = self.ring_api.devices() subscribed_device_ids = set(self.async_contexts()) @@ -104,18 +109,14 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): async with TaskGroup() as tg: if device.has_capability("history"): tg.create_task( - _call_api( - self.hass, + self._call_api( lambda device: device.async_history(limit=10), device, - msg_suffix=f" for device {device.name}", # device_id is the mac ) ) tg.create_task( - _call_api( - self.hass, + self._call_api( device.async_update_health_data, - msg_suffix=f" for device {device.name}", ) ) except ExceptionGroup as eg: diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index d48cc35a4f5..5d77bf3a285 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass +import logging from typing import Any, Concatenate, Generic, TypeVar, cast from ring_doorbell import ( @@ -36,6 +37,8 @@ _RingCoordinatorT = TypeVar( bound=(RingDataCoordinator | RingListenCoordinator), ) +_LOGGER = logging.getLogger(__name__) + @dataclass(slots=True) class DeprecatedInfo: @@ -62,14 +65,22 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( return await async_func(self, *args, **kwargs) except AuthenticationError as err: self.coordinator.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_authentication", + ) from err except RingTimeout as err: raise HomeAssistantError( - f"Timeout communicating with API {async_func}: {err}" + translation_domain=DOMAIN, + translation_key="api_timeout", ) from err except RingError as err: + _LOGGER.debug( + "Error calling %s in platform %s: ", async_func.__name__, self.platform + ) raise HomeAssistantError( - f"Error communicating with API{async_func}: {err}" + translation_domain=DOMAIN, + translation_key="api_error", ) from err return _wrap diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 219463d92d9..2d7e0b17da1 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -141,6 +141,20 @@ } } }, + "exceptions": { + "api_authentication": { + "message": "Authentication error communicating with Ring API" + }, + "api_timeout": { + "message": "Timeout communicating with Ring API" + }, + "api_error": { + "message": "Error communicating with Ring API" + }, + "sdp_m_line_index_required": { + "message": "Error negotiating stream for {device}" + } + }, "issues": { "deprecated_entity": { "title": "Detected deprecated {platform} entity usage", diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 4b4f019fdf7..54638df9a46 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -436,9 +436,9 @@ async def test_camera_webrtc( assert response assert response.get("success") is False assert response["error"]["code"] == "home_assistant_error" - msg = "The sdp_m_line_index is required for ring webrtc streaming" - assert msg in response["error"].get("message") - assert msg in caplog.text + error_msg = f"Error negotiating stream for {front_camera_mock.name}" + assert error_msg in response["error"].get("message") + assert error_msg in caplog.text front_camera_mock.on_webrtc_candidate.assert_called_once() # Answer message diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 7c3b93e5114..66decb5ce15 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.ring.const import ( CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL, ) -from homeassistant.components.ring.coordinator import RingEventListener +from homeassistant.components.ring.coordinator import RingConfigEntry, RingEventListener from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -80,12 +80,12 @@ async def test_auth_failed_on_setup( ("error_type", "log_msg"), [ ( - RingTimeout, - "Timeout communicating with API: ", + RingTimeout("Some internal error info"), + "Timeout communicating with Ring API", ), ( - RingError, - "Error communicating with API: ", + RingError("Some internal error info"), + "Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -95,6 +95,7 @@ async def test_error_on_setup( mock_ring_client, mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, error_type, log_msg, ) -> None: @@ -166,11 +167,11 @@ async def test_auth_failure_on_device_update( [ ( RingTimeout, - "Error fetching devices data: Timeout communicating with API: ", + "Error fetching devices data: Timeout communicating with Ring API", ), ( RingError, - "Error fetching devices data: Error communicating with API: ", + "Error fetching devices data: Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -178,7 +179,7 @@ async def test_auth_failure_on_device_update( async def test_error_on_global_update( hass: HomeAssistant, mock_ring_client, - mock_config_entry: MockConfigEntry, + mock_config_entry: RingConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, error_type, @@ -189,15 +190,35 @@ async def test_error_on_global_update( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - mock_ring_client.async_update_devices.side_effect = error_type + coordinator = mock_config_entry.runtime_data.devices_coordinator + assert coordinator - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch.object( + coordinator, "_async_update_data", wraps=coordinator._async_update_data + ) as refresh_spy: + error = error_type("Some internal error info 1") + mock_ring_client.async_update_devices.side_effect = error - assert log_msg in caplog.text + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error + assert log_msg in caplog.text + + # Check log is not being spammed. + refresh_spy.reset_mock() + error2 = error_type("Some internal error info 2") + caplog.clear() + mock_ring_client.async_update_devices.side_effect = error2 + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error2 + assert log_msg not in caplog.text @pytest.mark.parametrize( @@ -205,11 +226,11 @@ async def test_error_on_global_update( [ ( RingTimeout, - "Error fetching devices data: Timeout communicating with API for device Front: ", + "Error fetching devices data: Timeout communicating with Ring API", ), ( RingError, - "Error fetching devices data: Error communicating with API for device Front: ", + "Error fetching devices data: Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -218,7 +239,7 @@ async def test_error_on_device_update( hass: HomeAssistant, mock_ring_client, mock_ring_devices, - mock_config_entry: MockConfigEntry, + mock_config_entry: RingConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, error_type, @@ -229,15 +250,36 @@ async def test_error_on_device_update( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - front_door_doorbell = mock_ring_devices.get_device(765432) - front_door_doorbell.async_history.side_effect = error_type + coordinator = mock_config_entry.runtime_data.devices_coordinator + assert coordinator - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch.object( + coordinator, "_async_update_data", wraps=coordinator._async_update_data + ) as refresh_spy: + error = error_type("Some internal error info 1") + front_door_doorbell = mock_ring_devices.get_device(765432) + front_door_doorbell.async_history.side_effect = error - assert log_msg in caplog.text - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error + assert log_msg in caplog.text + + # Check log is not being spammed. + error2 = error_type("Some internal error info 2") + front_door_doorbell.async_history.side_effect = error2 + refresh_spy.reset_mock() + caplog.clear() + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error2 + assert log_msg not in caplog.text @pytest.mark.parametrize(