diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 38b9cae055c..9221430413a 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -8,7 +8,6 @@ import logging from typing import Optional from haffmpeg.camera import CameraMjpeg -import requests from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera @@ -20,7 +19,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR from .coordinator import RingDataCoordinator -from .entity import RingEntity +from .entity import RingEntity, exception_wrap FORCE_REFRESH_INTERVAL = timedelta(minutes=3) @@ -145,17 +144,11 @@ class RingCam(RingEntity, Camera): if self._last_video_id != self._last_event["id"]: self._image = None - try: - video_url = await self.hass.async_add_executor_job( - self._device.recording_url, self._last_event["id"] - ) - except requests.Timeout: - _LOGGER.warning( - "Time out fetching recording url for camera %s", self.entity_id - ) - video_url = None + self._video_url = await self.hass.async_add_executor_job(self._get_video) - if video_url: - self._last_video_id = self._last_event["id"] - self._video_url = video_url - self._expires_at = FORCE_REFRESH_INTERVAL + utcnow + self._last_video_id = self._last_event["id"] + self._expires_at = FORCE_REFRESH_INTERVAL + utcnow + + @exception_wrap + def _get_video(self) -> str: + return self._device.recording_url(self._last_event["id"]) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 94dbb1ba987..e5b17ca135e 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,10 +1,11 @@ """Base class for Ring entity.""" +from collections.abc import Callable +from typing import Any, Concatenate, ParamSpec, TypeVar -from typing import TypeVar - -from ring_doorbell.generic import RingGeneric +import ring_doorbell from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,6 +20,33 @@ _RingCoordinatorT = TypeVar( "_RingCoordinatorT", bound=(RingDataCoordinator | RingNotificationsCoordinator), ) +_T = TypeVar("_T", bound="RingEntity") +_P = ParamSpec("_P") + + +def exception_wrap( + func: Callable[Concatenate[_T, _P], Any], +) -> Callable[Concatenate[_T, _P], Any]: + """Define a wrapper to catch exceptions and raise HomeAssistant errors.""" + + def _wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + return func(self, *args, **kwargs) + except ring_doorbell.AuthenticationError as err: + self.hass.loop.call_soon_threadsafe( + self.coordinator.config_entry.async_start_reauth, self.hass + ) + raise HomeAssistantError(err) from err + except ring_doorbell.RingTimeout as err: + raise HomeAssistantError( + f"Timeout communicating with API {func}: {err}" + ) from err + except ring_doorbell.RingError as err: + raise HomeAssistantError( + f"Error communicating with API{func}: {err}" + ) from err + + return _wrap class RingEntity(CoordinatorEntity[_RingCoordinatorT]): @@ -30,7 +58,7 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]): def __init__( self, - device: RingGeneric, + device: ring_doorbell.RingGeneric, coordinator: _RingCoordinatorT, ) -> None: """Initialize a sensor for Ring device.""" @@ -51,7 +79,7 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]): return device_data return None - def _get_coordinator_device(self) -> RingGeneric | None: + def _get_coordinator_device(self) -> ring_doorbell.RingGeneric | None: if (device_data := self._get_coordinator_device_data()) and ( device := device_data.device ): diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 1d3dde97ae0..36203aed1eb 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -4,7 +4,6 @@ from datetime import timedelta import logging from typing import Any -import requests from ring_doorbell import RingStickUpCam from ring_doorbell.generic import RingGeneric @@ -16,7 +15,7 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR from .coordinator import RingDataCoordinator -from .entity import RingEntity +from .entity import RingEntity, exception_wrap _LOGGER = logging.getLogger(__name__) @@ -76,13 +75,10 @@ class RingLight(RingEntity, LightEntity): self._attr_is_on = device.lights == ON_STATE super()._handle_coordinator_update() + @exception_wrap def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" - try: - self._device.lights = new_state - except requests.Timeout: - _LOGGER.error("Time out setting %s light to %s", self.entity_id, new_state) - return + self._device.lights = new_state self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 60290469fa7..b746889d439 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR from .coordinator import RingDataCoordinator -from .entity import RingEntity +from .entity import RingEntity, exception_wrap _LOGGER = logging.getLogger(__name__) @@ -49,6 +49,7 @@ class RingChimeSiren(RingEntity, SirenEntity): # Entity class attributes self._attr_unique_id = f"{self._device.id}-siren" + @exception_wrap def turn_on(self, **kwargs: Any) -> None: """Play the test sound on a Ring Chime device.""" tone = kwargs.get(ATTR_TONE) or KIND_DING diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index d2b93cf7154..3c45a1749f8 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -4,7 +4,6 @@ from datetime import timedelta import logging from typing import Any -import requests from ring_doorbell import RingStickUpCam from ring_doorbell.generic import RingGeneric @@ -16,7 +15,7 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR from .coordinator import RingDataCoordinator -from .entity import RingEntity +from .entity import RingEntity, exception_wrap _LOGGER = logging.getLogger(__name__) @@ -83,13 +82,10 @@ class SirenSwitch(BaseRingSwitch): self._attr_is_on = device.siren > 0 super()._handle_coordinator_update() + @exception_wrap def _set_switch(self, new_state): """Update switch state, and causes Home Assistant to correctly update.""" - try: - self._device.siren = new_state - except requests.Timeout: - _LOGGER.error("Time out setting %s siren to %s", self.entity_id, new_state) - return + self._device.siren = new_state self._attr_is_on = new_state > 0 self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index fd58e0ffbee..9680c21abfc 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -1,9 +1,14 @@ """The tests for the Ring light platform.""" +from unittest.mock import PropertyMock, patch +import pytest import requests_mock +import ring_doorbell +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -90,3 +95,44 @@ async def test_updates_work( state = hass.states.get("light.front_light") assert state.state == "on" + + +@pytest.mark.parametrize( + ("exception_type", "reauth_expected"), + [ + (ring_doorbell.AuthenticationError, True), + (ring_doorbell.RingTimeout, False), + (ring_doorbell.RingError, False), + ], + ids=["Authentication", "Timeout", "Other"], +) +async def test_light_errors_when_turned_on( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + exception_type, + reauth_expected, +) -> None: + """Tests the light turns on correctly.""" + await setup_platform(hass, Platform.LIGHT) + config_entry = hass.config_entries.async_entries("ring")[0] + + assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + with patch.object( + ring_doorbell.RingStickUpCam, "lights", new_callable=PropertyMock + ) as mock_lights: + mock_lights.side_effect = exception_type + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", "turn_on", {"entity_id": "light.front_light"}, blocking=True + ) + await hass.async_block_till_done() + assert mock_lights.call_count == 1 + assert ( + any( + flow + for flow in config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}) + if flow["handler"] == "ring" + ) + == reauth_expected + ) diff --git a/tests/components/ring/test_siren.py b/tests/components/ring/test_siren.py index 9a54a739fbd..132c75996a9 100644 --- a/tests/components/ring/test_siren.py +++ b/tests/components/ring/test_siren.py @@ -1,9 +1,14 @@ """The tests for the Ring button platform.""" +from unittest.mock import patch +import pytest import requests_mock +import ring_doorbell +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -145,3 +150,46 @@ async def test_motion_chime_can_be_played( state = hass.states.get("siren.downstairs_siren") assert state.state == "unknown" + + +@pytest.mark.parametrize( + ("exception_type", "reauth_expected"), + [ + (ring_doorbell.AuthenticationError, True), + (ring_doorbell.RingTimeout, False), + (ring_doorbell.RingError, False), + ], + ids=["Authentication", "Timeout", "Other"], +) +async def test_siren_errors_when_turned_on( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + exception_type, + reauth_expected, +) -> None: + """Tests the siren turns on correctly.""" + await setup_platform(hass, Platform.SIREN) + config_entry = hass.config_entries.async_entries("ring")[0] + + assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + with patch.object( + ring_doorbell.RingChime, "test_sound", side_effect=exception_type + ) as mock_siren: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": "siren.downstairs_siren", "tone": "motion"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_siren.call_count == 1 + assert ( + any( + flow + for flow in config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}) + if flow["handler"] == "ring" + ) + == reauth_expected + ) diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 0012db61f97..e8e860cc162 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,14 @@ """The tests for the Ring switch platform.""" +from unittest.mock import PropertyMock, patch +import pytest import requests_mock +import ring_doorbell +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -97,3 +102,44 @@ async def test_updates_work( state = hass.states.get("switch.front_siren") assert state.state == "on" + + +@pytest.mark.parametrize( + ("exception_type", "reauth_expected"), + [ + (ring_doorbell.AuthenticationError, True), + (ring_doorbell.RingTimeout, False), + (ring_doorbell.RingError, False), + ], + ids=["Authentication", "Timeout", "Other"], +) +async def test_switch_errors_when_turned_on( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + exception_type, + reauth_expected, +) -> None: + """Tests the switch turns on correctly.""" + await setup_platform(hass, Platform.SWITCH) + config_entry = hass.config_entries.async_entries("ring")[0] + + assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + with patch.object( + ring_doorbell.RingStickUpCam, "siren", new_callable=PropertyMock + ) as mock_switch: + mock_switch.side_effect = exception_type + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.front_siren"}, blocking=True + ) + await hass.async_block_till_done() + assert mock_switch.call_count == 1 + assert ( + any( + flow + for flow in config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}) + if flow["handler"] == "ring" + ) + == reauth_expected + )