Add exception translations to ring integration (#136468)

* Add exception translations to ring integration

* Do not include exception details in exception translations

* Don't check last_update_success for auth errors and update tests

* Do not log errors twice

* Update post review
This commit is contained in:
Steven B. 2025-02-04 15:14:48 +00:00 committed by GitHub
parent 5629b995ce
commit 2f5816c5b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 134 additions and 60 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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