Skip to content

Commit

Permalink
Enable ring event listener to fix missing notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
sdb9696 committed Feb 1, 2024
1 parent 1af25bc commit f3a4a34
Show file tree
Hide file tree
Showing 10 changed files with 361 additions and 32 deletions.
2 changes: 0 additions & 2 deletions homeassistant/components/ring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ def token_updater(token):
devices_coordinator = RingDataCoordinator(hass, ring)
notifications_coordinator = RingNotificationsCoordinator(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] = {
RING_API: ring,
Expand All @@ -63,7 +62,6 @@ async def async_refresh_all(_: ServiceCall) -> None:
"""Refresh all ring data."""
for info in hass.data[DOMAIN].values():
await info[RING_DEVICES_COORDINATOR].async_refresh()
await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh()

# register service
hass.services.async_register(DOMAIN, "update", async_refresh_all)
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/ring/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR
from .coordinator import RingNotificationsCoordinator
from .entity import RingEntity
from .entity import RingNotificationsEntity


@dataclass(frozen=True)
Expand Down Expand Up @@ -71,7 +71,7 @@ async def async_setup_entry(
async_add_entities(entities)


class RingBinarySensor(RingEntity, BinarySensorEntity):
class RingBinarySensor(RingNotificationsEntity, BinarySensorEntity):
"""A binary sensor implementation for Ring device."""

_active_alert: dict[str, Any] | None = None
Expand Down
137 changes: 119 additions & 18 deletions homeassistant/components/ring/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,37 @@
from asyncio import TaskGroup
from collections.abc import Callable
from dataclasses import dataclass
import functools
import logging
from typing import Any, Optional
from typing import Any, Optional, TypeVar

import ring_doorbell
from ring_doorbell.generic import RingGeneric

from homeassistant.core import HomeAssistant
from ring_doorbell import listen

from homeassistant import config_entries
from homeassistant.core import (
CALLBACK_TYPE,
HassJob,
HassJobType,
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
from .const import SCAN_INTERVAL

_LOGGER = logging.getLogger(__name__)

_RingNotificationsCoordinatorT = TypeVar(
"_RingNotificationsCoordinatorT",
bound="RingNotificationsCoordinator",
)


async def _call_api(
hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = ""
Expand All @@ -38,7 +55,7 @@ async def _call_api(
class RingDeviceData:
"""RingDeviceData."""

device: RingGeneric
device: ring_doorbell.RingGeneric
history: Optional[list] = None


Expand Down Expand Up @@ -66,7 +83,7 @@ async def _async_update_data(self):
await _call_api(self.hass, getattr(self.ring_api, update_method))
self.first_call = False
data: dict[str, RingDeviceData] = {}
devices: dict[str : list[RingGeneric]] = self.ring_api.devices()
devices: dict[str : list[ring_doorbell.RingGeneric]] = self.ring_api.devices()
subscribed_device_ids = set(self.async_contexts())
for device_type in devices:
for device in devices[device_type]:
Expand Down Expand Up @@ -101,19 +118,103 @@ async def _async_update_data(self):
return data


class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
class RingNotificationsCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Global notifications coordinator."""

def __init__(self, hass: HomeAssistant, ring_api: ring_doorbell.Ring) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name="active dings",
update_interval=NOTIFICATIONS_SCAN_INTERVAL,
)
self.hass = hass
self.logger = _LOGGER
self.ring_api: ring_doorbell.Ring = ring_api

async def _async_update_data(self):
"""Fetch data from API endpoint."""
self.config = listen.RingEventListenerConfig.default_config
self.event_listener = listen.RingEventListener(ring_api, config=self.config)
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
self._listen_callback_id = None
base_job_name = "Ring.NotificationsCoordinator"
self.config_entry = config_entries.current_entry.get()
if entry := self.config_entry:
base_job_name += f" {entry.title} {entry.domain} {entry.entry_id}"
self._start_job = HassJob(
self._async_start_listen,
base_job_name + " Start Listen",
job_type=HassJobType.Coroutinefunction,
)
self._stop_job = HassJob(
self._async_stop_listen,
base_job_name + " Stop Listen",
job_type=HassJobType.Coroutinefunction,
)
self.start_timeout = 10
self._start_func = functools.partial(
self.event_listener.start,
listen_loop=self.hass.loop,
callback_loop=self.hass.loop,
timeout=self.start_timeout,
)
if self.config_entry:
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()

Check warning on line 160 in homeassistant/components/ring/coordinator.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/ring/coordinator.py#L160

Added line #L160 was not covered by tests

def _start_listen(self) -> None:
self.hass.loop.call_later(0, self.hass.async_run_hass_job, self._start_job)

def _stop_listen(self) -> None:
self.hass.loop.call_later(0, self.hass.async_run_hass_job, self._stop_job)

async def _async_stop_listen(self) -> None:
self.logger.debug("Stopped ring listener")
await self.hass.async_add_executor_job(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.hass.async_add_executor_job(self._start_func)
if self.event_listener.started:
self.logger.debug("Started ring listener")
else:
self.logger.warning(
"Ring event listener failed to started 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: ring_doorbell.RingEvent):
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._stop_listen()

self._listeners[remove_listener] = (update_callback, context)

# This is the first listener, start the event listener.
if start_listen:
self._start_listen()
return remove_listener
66 changes: 57 additions & 9 deletions homeassistant/components/ring/entity.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""Base class for Ring entity."""
from typing import TypeVar
from typing import TypeVar, cast

from ring_doorbell.generic import RingGeneric

from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
CoordinatorEntity,
)

from .const import ATTRIBUTION, DOMAIN
from .coordinator import (
Expand All @@ -14,14 +18,20 @@
RingNotificationsCoordinator,
)

_RingCoordinatorT = TypeVar(
"_RingCoordinatorT",
bound=(RingDataCoordinator | RingNotificationsCoordinator),
_RingNotificationsCoordinatorT = TypeVar(
"_RingNotificationsCoordinatorT",
bound="RingNotificationsCoordinator",
)

_CoordinatorEntityT = TypeVar(
"_CoordinatorEntityT",
# bound=(BaseCoordinatorEntity | CoordinatorEntity),
bound=BaseCoordinatorEntity,
)


class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
"""Base implementation for Ring device."""
class RingBaseEntity(Entity):
"""Base class for ring entities."""

_attr_attribution = ATTRIBUTION
_attr_should_poll = False
Expand All @@ -30,10 +40,8 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
def __init__(
self,
device: RingGeneric,
coordinator: _RingCoordinatorT,
) -> None:
"""Initialize a sensor for Ring device."""
super().__init__(coordinator, context=device.id)
self._device = device
self._attr_extra_state_attributes = {}
self._attr_device_info = DeviceInfo(
Expand All @@ -43,6 +51,21 @@ def __init__(
name=device.name,
)


class RingEntity(RingBaseEntity, CoordinatorEntity[RingDataCoordinator]):
"""Base implementation for Ring device."""

def __init__(
self,
device: RingGeneric,
coordinator: RingDataCoordinator,
) -> None:
"""Initialize a sensor for Ring device."""
super().__init__(device=device)
super(CoordinatorEntity, self).__init__(
coordinator=coordinator, context=device.id
)

def _get_coordinator_device_data(self) -> RingDeviceData | None:
if (data := self.coordinator.data) and (
device_data := data.get(self._device.id)
Expand All @@ -69,3 +92,28 @@ def _handle_coordinator_update(self) -> None:
if device := self._get_coordinator_device():
self._device = device
super()._handle_coordinator_update()


class RingNotificationsEntity(
RingBaseEntity, BaseCoordinatorEntity[_RingNotificationsCoordinatorT]
):
"""A class for entities using RingNotificationsCoordinator."""

def __init__(
self,
device: RingGeneric,
coordinator: RingNotificationsCoordinator,
) -> None:
"""Initialize a sensor for Ring device."""
super().__init__(device)
BaseCoordinatorEntity.__init__(
cast(BaseCoordinatorEntity, self), coordinator, context=device.id
)

async def async_update(self) -> None:
"""All updates are passive."""

@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.event_listener.started
Loading

0 comments on commit f3a4a34

Please sign in to comment.