diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 14ab435fda6117..4d8c0cbae06318 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -16,7 +16,11 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, PLATFORMS -from .coordinator import RingDataCoordinator, RingNotificationsCoordinator +from .coordinator import ( + RingDataCoordinator, + RingListenCoordinator, + RingNotificationsCoordinator, +) _LOGGER = logging.getLogger(__name__) @@ -29,9 +33,13 @@ class RingData: devices: RingDevices devices_coordinator: RingDataCoordinator notifications_coordinator: RingNotificationsCoordinator + listen_coordinator: RingListenCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type RingConfigEntry = ConfigEntry[RingData] + + +async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Set up a config entry.""" def token_updater(token: dict[str, Any]) -> None: @@ -53,14 +61,17 @@ def token_updater(token: dict[str, Any]) -> None: devices_coordinator = RingDataCoordinator(hass, ring) notifications_coordinator = RingNotificationsCoordinator(hass, ring) + listen_coordinator = RingListenCoordinator(hass, ring) + await devices_coordinator.async_config_entry_first_refresh() await notifications_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RingData( + entry.runtime_data = RingData( api=ring, devices=ring.devices(), devices_coordinator=devices_coordinator, notifications_coordinator=notifications_coordinator, + listen_coordinator=listen_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,10 +98,9 @@ async def async_refresh_all(_: ServiceCall) -> None: translation_key="deprecated_service_ring_update", ) - for info in hass.data[DOMAIN].values(): - ring_data = cast(RingData, info) - await ring_data.devices_coordinator.async_refresh() - await ring_data.notifications_coordinator.async_refresh() + ring_data = entry.runtime_data + await ring_data.devices_coordinator.async_refresh() + await ring_data.notifications_coordinator.async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2db04cfd46168f..6a053d82e47bad 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -14,12 +14,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingNotificationsCoordinator from .entity import RingBaseEntity @@ -50,11 +48,11 @@ class RingBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data entities = [ RingBinarySensor( @@ -136,3 +134,6 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat() return attrs + + async def async_update(self) -> None: + """All updates are passive.""" diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index c8d7d902d183c1..d68fa15fb6cad7 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -5,12 +5,10 @@ from ring_doorbell import RingOther from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -21,11 +19,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the buttons for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index b45803f3618168..c5b97426cf6ee2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -12,14 +12,12 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -31,11 +29,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data devices_coordinator = ring_data.devices_coordinator ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 70813a78c76b9e..bd81a99977958c 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -18,6 +18,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SIREN, diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index 600743005ebcb9..43b3ada801f337 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -3,13 +3,26 @@ from asyncio import TaskGroup from collections.abc import Callable, Coroutine import logging -from typing import Any - -from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout - -from homeassistant.core import HomeAssistant +from typing import TYPE_CHECKING, Any + +from ring_doorbell import ( + AuthenticationError, + Ring, + RingDevices, + RingError, + RingEvent, + RingTimeout, +) +from ring_doorbell.listen import RingEventListener, RingEventListenerConfig + +from homeassistant import config_entries +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + BaseDataUpdateCoordinatorProtocol, + DataUpdateCoordinator, + UpdateFailed, +) from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL @@ -107,3 +120,99 @@ def __init__(self, hass: HomeAssistant, ring_api: Ring) -> None: async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" await _call_api(self.hass, self.ring_api.async_update_dings) + + +class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): + """Global notifications coordinator.""" + + config_entry: config_entries.ConfigEntry + + def __init__(self, hass: HomeAssistant, ring_api: Ring) -> None: + """Initialize my coordinator.""" + self.hass = hass + self.logger = _LOGGER + self.ring_api: Ring = ring_api + self.config = RingEventListenerConfig.default_config() + self.event_listener = RingEventListener(ring_api, config=self.config) + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listen_callback_id: int | None = None + + config_entry = config_entries.current_entry.get() + if TYPE_CHECKING: + assert config_entry + self.config_entry = config_entry + self.start_timeout = 10 + self.config_entry.async_on_unload(self.async_shutdown) + + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and ignore new runs.""" + if self.event_listener.started: + await self._async_stop_listen() + + async def _async_stop_listen(self) -> None: + self.logger.debug("Stopped ring listener") + self.event_listener.stop() + self.logger.debug("Stopped ring listener") + + async def _async_start_listen(self) -> None: + """Start listening for realtime events.""" + self.logger.debug("Starting ring listener with config: %s", self.config) + await self.event_listener.async_start( + listen_loop=self.hass.loop, + callback_loop=self.hass.loop, + timeout=self.start_timeout, + ) + if self.event_listener.started is True: + self.logger.debug("Started ring listener") + else: + self.logger.warning( + "Ring event listener failed to start after %s seconds", + self.start_timeout, + ) + self._listen_callback_id = self.event_listener.add_notification_callback( + self._on_event + ) + await _call_api(self.hass, self.ring_api.update_dings) + self._async_update_listeners() + + def _on_event(self, event: RingEvent) -> None: + self.logger.debug("Ring event received: %s", event) + self._async_update_listeners() + self.hass.loop.call_later(event.expires_in, self._async_update_listeners) + + @callback + def _async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + start_listen = not self._listeners + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + if not self._listeners: + self.config_entry.async_create_task( + self.hass, + self._async_stop_listen(), + "Ring event listener stop", + eager_start=True, + ) + + self._listeners[remove_listener] = (update_callback, context) + + # This is the first listener, start the event listener. + if start_listen: + self.config_entry.async_create_task( + self.hass, + self._async_start_listen(), + "Ring event listener start", + eager_start=True, + ) + return remove_listener diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 72deb09b76fc17..b6166e4cbde684 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -15,16 +15,23 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + CoordinatorEntity, +) from .const import ATTRIBUTION, DOMAIN -from .coordinator import RingDataCoordinator, RingNotificationsCoordinator +from .coordinator import ( + RingDataCoordinator, + RingListenCoordinator, + RingNotificationsCoordinator, +) RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric) _RingCoordinatorT = TypeVar( "_RingCoordinatorT", - bound=(RingDataCoordinator | RingNotificationsCoordinator), + bound=(RingDataCoordinator | RingNotificationsCoordinator | RingListenCoordinator), ) @@ -52,7 +59,7 @@ async def _wrap(self: _RingBaseEntityT, *args: _P.args, **kwargs: _P.kwargs) -> class RingBaseEntity( - CoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT] + BaseCoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT] ): """Base implementation for Ring device.""" @@ -77,7 +84,7 @@ def __init__( ) -class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT]): +class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT], CoordinatorEntity): """Implementation for Ring devices.""" def _get_coordinator_data(self) -> RingDevices: diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py new file mode 100644 index 00000000000000..b9746faef0dff2 --- /dev/null +++ b/homeassistant/components/ring/event.py @@ -0,0 +1,104 @@ +"""Component providing support for ring events.""" + +from dataclasses import dataclass +from typing import Any, Generic + +from ring_doorbell import RingCapability + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RingConfigEntry +from .coordinator import RingListenCoordinator +from .entity import RingBaseEntity, RingDeviceT + + +@dataclass(frozen=True, kw_only=True) +class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): + """Base class for event entity description.""" + + capability: RingCapability + + +EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = ( + RingEventEntityDescription( + key="motion", + device_class=EventDeviceClass.DOORBELL, + event_types=["ding"], + entity_registry_enabled_default=True, + capability=RingCapability.HISTORY, + ), + RingEventEntityDescription( + key="ding", + device_class=EventDeviceClass.MOTION, + event_types=["motion"], + entity_registry_enabled_default=True, + capability=RingCapability.MOTION_DETECTION, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RingConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a sensor for a Ring device.""" + ring_data = entry.runtime_data + listen_coordinator = ring_data.listen_coordinator + + entities = [ + RingEvent(device, listen_coordinator, description) + for description in EVENT_DESCRIPTIONS + for device in ring_data.devices.all_devices + if device.has_capability(description.capability) + ] + + async_add_entities(entities) + + +class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity): + """A sensor implementation for Ring device.""" + + entity_description: RingEventEntityDescription[RingDeviceT] + + def __init__( + self, + device: RingDeviceT, + coordinator: RingListenCoordinator, + description: RingEventEntityDescription[RingDeviceT], + ) -> None: + """Initialize a sensor for Ring device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{device.id}-{description.key}" + self._attr_entity_registry_enabled_default = ( + description.entity_registry_enabled_default + ) + + @callback + def _async_handle_event(self, event: str) -> None: + """Handle the event.""" + self._trigger_event(event, {"extra_data": 123}) + self.async_write_ha_state() + + def _get_coordinator_alert(self) -> Any: + return self.coordinator + + @callback + def _handle_coordinator_update(self) -> None: + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + # return self.coordinator.event_listener.started + return True + + async def async_update(self) -> None: + """All updates are passive.""" diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index f7f7f9b44aec9c..bf52bea4c20ba4 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -8,13 +8,11 @@ from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -38,11 +36,11 @@ class OnOffState(StrEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index b6849e37d966b3..786ae552f6830d 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -21,7 +21,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -31,19 +30,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data devices_coordinator = ring_data.devices_coordinator entities = [ diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 665de07a5bbcae..46f61f7b2b1c6d 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -6,12 +6,10 @@ from ring_doorbell import RingChime, RingEventKind from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -20,11 +18,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 810011d68c86f9..367d65ce50ca9e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -12,8 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import RingData -from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -34,7 +32,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = config_entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 4456a9daa26278..4d7e73ebf9c5e4 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -4,6 +4,7 @@ from itertools import chain from unittest.mock import AsyncMock, Mock, create_autospec, patch +from google.protobuf.json_format import Parse as JsonParse import pytest import ring_doorbell @@ -13,7 +14,7 @@ from .device_mocks import get_active_alerts, get_devices_data, get_mock_devices -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -135,3 +136,52 @@ async def mock_added_config_entry( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() return mock_config_entry + + +def load_fixture_as_protobuf_msg(filename, msg_class): + """Load a fixture.""" + msg = msg_class() + JsonParse(load_fixture(filename, "ring"), msg) + return msg + + +@pytest.fixture(autouse=True) +def mock_listener(request): + """Fixture to mock the push client connect and disconnect.""" + + f = _FakeRingListener() + with patch( + "homeassistant.components.ring.coordinator.RingEventListener", return_value=f + ): + yield f + + +class _FakeRingListener: + """Test class to replace the ring_doorbell event listener for testing.""" + + def __init__(self, *_, **__): + self._callbacks = {} + self._subscription_counter = 1 + self.started = False + self.do_not_start = False + + def start(self, *_, **__): + if self.do_not_start: + return False + self.started = True + return True + + def stop(self, *_, **__): + self.started = False + + def add_notification_callback(self, callback): + self._callbacks[self._subscription_counter] = callback + self._subscription_counter += 1 + return self._subscription_counter + + def remove_notification_callback(self, subscription_id): + del self._callbacks[subscription_id] + + def notify(self, ring_event: ring_doorbell.RingEvent): + for callback in self._callbacks.values(): + callback(ring_event) diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 16bc6e872c1d52..e125cb8dcd1898 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,10 +1,21 @@ """The tests for the Ring binary sensor platform.""" +from datetime import timedelta +from time import time +from unittest.mock import patch + +import ring_doorbell + +from homeassistant.components.ring import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .common import setup_platform +from tests.common import MockConfigEntry, async_fire_time_changed + async def test_binary_sensor(hass: HomeAssistant, mock_ring_client) -> None: """Test the Ring binary sensors.""" @@ -22,3 +33,63 @@ async def test_binary_sensor(hass: HomeAssistant, mock_ring_client) -> None: ingress_ding_state = hass.states.get("binary_sensor.ingress_ding") assert ingress_ding_state is not None assert ingress_ding_state.state == "off" + + +async def test_listen_alerts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_listener, +) -> None: + """Test the Ring binary sensors.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.ring.PLATFORMS", ["binary_sensor"]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + # Check the initial motion state is off + motion_state = hass.states.get("binary_sensor.front_door_motion") + assert motion_state is not None + assert motion_state.state == "off" + assert motion_state.attributes["device_class"] == "motion" + + expires_in = 10 + re = ring_doorbell.RingEvent( + 12, 987654, "Foo", "doorbot", time(), expires_in, "motion", "human" + ) + + with patch( + "ring_doorbell.Ring.active_alerts", + return_value=[re], + ): + # When the data coordinator gets the alert it uses the existing logic to + # check the active_alerts in the ring_doorbell.Ring object so we do both + # patch and notify + mock_listener.notify(re) + + motion_state = hass.states.get("binary_sensor.front_door_motion") + assert motion_state is not None + assert motion_state.state == "on" + assert motion_state.attributes["device_class"] == "motion" + # Check we only updated the correct sensor + ding_state = hass.states.get("binary_sensor.front_door_ding") + assert ding_state is not None + assert ding_state.state == "off" + + with patch( + "ring_doorbell.Ring.active_alerts", + return_value=[], + ): + # Check the alert hasn't cleared before the expirey time + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=expires_in / 2)) + await hass.async_block_till_done() + + motion_state = hass.states.get("binary_sensor.front_door_motion") + assert motion_state is not None + assert motion_state.state == "on" + assert motion_state.attributes["device_class"] == "motion" + # Check the alert has cleared at the expirey time + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=(expires_in))) + await hass.async_block_till_done() + motion_state = hass.states.get("binary_sensor.front_door_motion") + assert motion_state is not None + assert motion_state.state == "off" + assert motion_state.attributes["device_class"] == "motion" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 9281e9a930ad26..b152bc5c4eaa10 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -415,3 +415,23 @@ async def test_token_updated( async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_config_entry.data[CONF_TOKEN] == {"access_token": "new-mock-token"} + + +async def test_no_listen_start( + hass: HomeAssistant, caplog, mock_listener +) -> None: + """Test behaviour if listener doesn't start.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data={"username": "foo", "token": {}}, + ) + mock_listener.do_not_start = True + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert "Ring event listener failed to start after 10 seconds" in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ]