mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Ensure that the ring integration always raises HomeAssistantError for user actions (#109893)
* Wrap library exceptions in HomeAssistantErrors * Remove commented * Update post review * Update post second review
This commit is contained in:
parent
5e94858821
commit
eff0aac586
@ -8,7 +8,6 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from haffmpeg.camera import CameraMjpeg
|
from haffmpeg.camera import CameraMjpeg
|
||||||
import requests
|
|
||||||
|
|
||||||
from homeassistant.components import ffmpeg
|
from homeassistant.components import ffmpeg
|
||||||
from homeassistant.components.camera import Camera
|
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 .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
|
||||||
from .coordinator import RingDataCoordinator
|
from .coordinator import RingDataCoordinator
|
||||||
from .entity import RingEntity
|
from .entity import RingEntity, exception_wrap
|
||||||
|
|
||||||
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
|
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
|
||||||
|
|
||||||
@ -145,17 +144,11 @@ class RingCam(RingEntity, Camera):
|
|||||||
if self._last_video_id != self._last_event["id"]:
|
if self._last_video_id != self._last_event["id"]:
|
||||||
self._image = None
|
self._image = None
|
||||||
|
|
||||||
try:
|
self._video_url = await self.hass.async_add_executor_job(self._get_video)
|
||||||
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
|
|
||||||
|
|
||||||
if video_url:
|
self._last_video_id = self._last_event["id"]
|
||||||
self._last_video_id = self._last_event["id"]
|
self._expires_at = FORCE_REFRESH_INTERVAL + utcnow
|
||||||
self._video_url = video_url
|
|
||||||
self._expires_at = FORCE_REFRESH_INTERVAL + utcnow
|
@exception_wrap
|
||||||
|
def _get_video(self) -> str:
|
||||||
|
return self._device.recording_url(self._last_event["id"])
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
"""Base class for Ring entity."""
|
"""Base class for Ring entity."""
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Concatenate, ParamSpec, TypeVar
|
||||||
|
|
||||||
from typing import TypeVar
|
import ring_doorbell
|
||||||
|
|
||||||
from ring_doorbell.generic import RingGeneric
|
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
@ -19,6 +20,33 @@ _RingCoordinatorT = TypeVar(
|
|||||||
"_RingCoordinatorT",
|
"_RingCoordinatorT",
|
||||||
bound=(RingDataCoordinator | RingNotificationsCoordinator),
|
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]):
|
class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
|
||||||
@ -30,7 +58,7 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device: RingGeneric,
|
device: ring_doorbell.RingGeneric,
|
||||||
coordinator: _RingCoordinatorT,
|
coordinator: _RingCoordinatorT,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a sensor for Ring device."""
|
"""Initialize a sensor for Ring device."""
|
||||||
@ -51,7 +79,7 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
|
|||||||
return device_data
|
return device_data
|
||||||
return None
|
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 (
|
if (device_data := self._get_coordinator_device_data()) and (
|
||||||
device := device_data.device
|
device := device_data.device
|
||||||
):
|
):
|
||||||
|
@ -4,7 +4,6 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
|
||||||
from ring_doorbell import RingStickUpCam
|
from ring_doorbell import RingStickUpCam
|
||||||
from ring_doorbell.generic import RingGeneric
|
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 .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
|
||||||
from .coordinator import RingDataCoordinator
|
from .coordinator import RingDataCoordinator
|
||||||
from .entity import RingEntity
|
from .entity import RingEntity, exception_wrap
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -76,13 +75,10 @@ class RingLight(RingEntity, LightEntity):
|
|||||||
self._attr_is_on = device.lights == ON_STATE
|
self._attr_is_on = device.lights == ON_STATE
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
@exception_wrap
|
||||||
def _set_light(self, new_state):
|
def _set_light(self, new_state):
|
||||||
"""Update light state, and causes Home Assistant to correctly update."""
|
"""Update light state, and causes Home Assistant to correctly update."""
|
||||||
try:
|
self._device.lights = new_state
|
||||||
self._device.lights = new_state
|
|
||||||
except requests.Timeout:
|
|
||||||
_LOGGER.error("Time out setting %s light to %s", self.entity_id, new_state)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._attr_is_on = new_state == ON_STATE
|
self._attr_is_on = new_state == ON_STATE
|
||||||
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
|
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
|
||||||
|
@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
|
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
|
||||||
from .coordinator import RingDataCoordinator
|
from .coordinator import RingDataCoordinator
|
||||||
from .entity import RingEntity
|
from .entity import RingEntity, exception_wrap
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -49,6 +49,7 @@ class RingChimeSiren(RingEntity, SirenEntity):
|
|||||||
# Entity class attributes
|
# Entity class attributes
|
||||||
self._attr_unique_id = f"{self._device.id}-siren"
|
self._attr_unique_id = f"{self._device.id}-siren"
|
||||||
|
|
||||||
|
@exception_wrap
|
||||||
def turn_on(self, **kwargs: Any) -> None:
|
def turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Play the test sound on a Ring Chime device."""
|
"""Play the test sound on a Ring Chime device."""
|
||||||
tone = kwargs.get(ATTR_TONE) or KIND_DING
|
tone = kwargs.get(ATTR_TONE) or KIND_DING
|
||||||
|
@ -4,7 +4,6 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
|
||||||
from ring_doorbell import RingStickUpCam
|
from ring_doorbell import RingStickUpCam
|
||||||
from ring_doorbell.generic import RingGeneric
|
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 .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
|
||||||
from .coordinator import RingDataCoordinator
|
from .coordinator import RingDataCoordinator
|
||||||
from .entity import RingEntity
|
from .entity import RingEntity, exception_wrap
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -83,13 +82,10 @@ class SirenSwitch(BaseRingSwitch):
|
|||||||
self._attr_is_on = device.siren > 0
|
self._attr_is_on = device.siren > 0
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
@exception_wrap
|
||||||
def _set_switch(self, new_state):
|
def _set_switch(self, new_state):
|
||||||
"""Update switch state, and causes Home Assistant to correctly update."""
|
"""Update switch state, and causes Home Assistant to correctly update."""
|
||||||
try:
|
self._device.siren = new_state
|
||||||
self._device.siren = new_state
|
|
||||||
except requests.Timeout:
|
|
||||||
_LOGGER.error("Time out setting %s siren to %s", self.entity_id, new_state)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._attr_is_on = new_state > 0
|
self._attr_is_on = new_state > 0
|
||||||
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
|
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
"""The tests for the Ring light platform."""
|
"""The tests for the Ring light platform."""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
import requests_mock
|
import requests_mock
|
||||||
|
import ring_doorbell
|
||||||
|
|
||||||
|
from homeassistant.config_entries import SOURCE_REAUTH
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .common import setup_platform
|
from .common import setup_platform
|
||||||
@ -90,3 +95,44 @@ async def test_updates_work(
|
|||||||
|
|
||||||
state = hass.states.get("light.front_light")
|
state = hass.states.get("light.front_light")
|
||||||
assert state.state == "on"
|
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
|
||||||
|
)
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
"""The tests for the Ring button platform."""
|
"""The tests for the Ring button platform."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
import requests_mock
|
import requests_mock
|
||||||
|
import ring_doorbell
|
||||||
|
|
||||||
|
from homeassistant.config_entries import SOURCE_REAUTH
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .common import setup_platform
|
from .common import setup_platform
|
||||||
@ -145,3 +150,46 @@ async def test_motion_chime_can_be_played(
|
|||||||
|
|
||||||
state = hass.states.get("siren.downstairs_siren")
|
state = hass.states.get("siren.downstairs_siren")
|
||||||
assert state.state == "unknown"
|
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
|
||||||
|
)
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
"""The tests for the Ring switch platform."""
|
"""The tests for the Ring switch platform."""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
import requests_mock
|
import requests_mock
|
||||||
|
import ring_doorbell
|
||||||
|
|
||||||
|
from homeassistant.config_entries import SOURCE_REAUTH
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
@ -97,3 +102,44 @@ async def test_updates_work(
|
|||||||
|
|
||||||
state = hass.states.get("switch.front_siren")
|
state = hass.states.get("switch.front_siren")
|
||||||
assert state.state == "on"
|
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
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user