Fix ring notifications (#124879)

* Enable ring event listener to fix missing notifications

* Fix pylint test CI fail

* Reinstate binary sensor and add deprecation issues

* Add tests

* Update post review

* Remove PropertyMock

* Update post review

* Split out adding event platform
This commit is contained in:
Steven B. 2024-09-08 17:17:30 +01:00 committed by GitHub
parent 5405279273
commit b3d6f8861f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 720 additions and 144 deletions

View File

@ -5,18 +5,23 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any, cast
import uuid
from ring_doorbell import Auth, Ring, RingDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
instance_id,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, PLATFORMS
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS
from .coordinator import RingDataCoordinator, RingListenCoordinator
_LOGGER = logging.getLogger(__name__)
@ -28,12 +33,26 @@ class RingData:
api: Ring
devices: RingDevices
devices_coordinator: RingDataCoordinator
notifications_coordinator: RingNotificationsCoordinator
listen_coordinator: RingListenCoordinator
type RingConfigEntry = ConfigEntry[RingData]
async def get_auth_agent_id(hass: HomeAssistant) -> tuple[str, str]:
"""Return user-agent and hardware id for Auth instantiation.
user_agent will be the display name in the ring.com authorised devices.
hardware_id will uniquely describe the authorised HA device.
"""
user_agent = f"{APPLICATION_NAME}/{DOMAIN}-integration"
# Generate a new uuid from the instance_uuid to keep the HA one private
instance_uuid = uuid.UUID(hex=await instance_id.async_get(hass))
hardware_id = str(uuid.uuid5(instance_uuid, user_agent))
return user_agent, hardware_id
async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool:
"""Set up a config entry."""
@ -44,26 +63,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
data={**entry.data, CONF_TOKEN: token},
)
def listen_credentials_updater(token: dict[str, Any]) -> None:
"""Handle from async context when token is updated."""
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_LISTEN_CREDENTIALS: token},
)
user_agent, hardware_id = await get_auth_agent_id(hass)
client_session = async_get_clientsession(hass)
auth = Auth(
f"{APPLICATION_NAME}/{__version__}",
user_agent,
entry.data[CONF_TOKEN],
token_updater,
http_client_session=async_get_clientsession(hass),
hardware_id=hardware_id,
http_client_session=client_session,
)
ring = Ring(auth)
await _migrate_old_unique_ids(hass, entry.entry_id)
devices_coordinator = RingDataCoordinator(hass, ring)
notifications_coordinator = RingNotificationsCoordinator(hass, ring)
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
listen_coordinator = RingListenCoordinator(
hass, ring, listen_credentials, listen_credentials_updater
)
await devices_coordinator.async_config_entry_first_refresh()
await notifications_coordinator.async_config_entry_first_refresh()
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)
@ -91,7 +123,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
)
for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN):
await loaded_entry.runtime_data.devices_coordinator.async_refresh()
await loaded_entry.runtime_data.notifications_coordinator.async_refresh()
# register service
hass.services.async_register(DOMAIN, "update", async_refresh_all)

View File

@ -2,46 +2,62 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from typing import Any, Generic
from ring_doorbell import Ring, RingEvent, RingGeneric
from ring_doorbell import RingCapability, RingEvent
from ring_doorbell.const import KIND_DING, KIND_MOTION
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_at
from . import RingConfigEntry
from .coordinator import RingNotificationsCoordinator
from .entity import RingBaseEntity
from .coordinator import RingListenCoordinator
from .entity import (
DeprecatedInfo,
RingBaseEntity,
RingDeviceT,
RingEntityDescription,
async_check_create_deprecated,
)
@dataclass(frozen=True, kw_only=True)
class RingBinarySensorEntityDescription(BinarySensorEntityDescription):
class RingBinarySensorEntityDescription(
BinarySensorEntityDescription, RingEntityDescription, Generic[RingDeviceT]
):
"""Describes Ring binary sensor entity."""
exists_fn: Callable[[RingGeneric], bool]
capability: RingCapability
BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
RingBinarySensorEntityDescription(
key="ding",
translation_key="ding",
key=KIND_DING,
translation_key=KIND_DING,
device_class=BinarySensorDeviceClass.OCCUPANCY,
exists_fn=lambda device: device.family
in {"doorbots", "authorized_doorbots", "other"},
capability=RingCapability.DING,
deprecated_info=DeprecatedInfo(
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
),
),
RingBinarySensorEntityDescription(
key="motion",
key=KIND_MOTION,
translation_key=KIND_MOTION,
device_class=BinarySensorDeviceClass.MOTION,
exists_fn=lambda device: device.family
in {"doorbots", "authorized_doorbots", "stickup_cams"},
capability=RingCapability.MOTION_DETECTION,
deprecated_info=DeprecatedInfo(
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
),
),
)
@ -53,70 +69,84 @@ async def async_setup_entry(
) -> None:
"""Set up the Ring binary sensors from a config entry."""
ring_data = entry.runtime_data
listen_coordinator = ring_data.listen_coordinator
entities = [
RingBinarySensor(
ring_data.api,
device,
ring_data.notifications_coordinator,
description,
)
async_add_entities(
RingBinarySensor(device, listen_coordinator, description)
for description in BINARY_SENSOR_TYPES
for device in ring_data.devices.all_devices
if description.exists_fn(device)
]
async_add_entities(entities)
if device.has_capability(description.capability)
and async_check_create_deprecated(
hass,
Platform.BINARY_SENSOR,
f"{device.id}-{description.key}",
description,
)
)
class RingBinarySensor(
RingBaseEntity[RingNotificationsCoordinator], BinarySensorEntity
RingBaseEntity[RingListenCoordinator, RingDeviceT], BinarySensorEntity
):
"""A binary sensor implementation for Ring device."""
_active_alert: RingEvent | None = None
entity_description: RingBinarySensorEntityDescription
RingBinarySensorEntityDescription[RingDeviceT]
def __init__(
self,
ring: Ring,
device: RingGeneric,
coordinator: RingNotificationsCoordinator,
description: RingBinarySensorEntityDescription,
device: RingDeviceT,
coordinator: RingListenCoordinator,
description: RingBinarySensorEntityDescription[RingDeviceT],
) -> None:
"""Initialize a sensor for Ring device."""
"""Initialize a binary sensor for Ring device."""
super().__init__(
device,
coordinator,
)
self.entity_description = description
self._ring = ring
self._attr_unique_id = f"{device.id}-{description.key}"
self._update_alert()
self._attr_is_on = False
self._active_alert: RingEvent | None = None
self._cancel_callback: CALLBACK_TYPE | None = None
@callback
def _handle_coordinator_update(self, _: Any = None) -> None:
"""Call update method."""
self._update_alert()
super()._handle_coordinator_update()
def _async_handle_event(self, alert: RingEvent) -> None:
"""Handle the event."""
self._attr_is_on = True
self._active_alert = alert
loop = self.hass.loop
when = loop.time() + alert.expires_in
if self._cancel_callback:
self._cancel_callback()
self._cancel_callback = async_call_at(self.hass, self._async_cancel_event, when)
@callback
def _update_alert(self) -> None:
"""Update active alert."""
self._active_alert = next(
(
alert
for alert in self._ring.active_alerts()
if alert["kind"] == self.entity_description.key
and alert["doorbot_id"] == self._device.id
),
None,
def _async_cancel_event(self, _now: Any) -> None:
"""Clear the event."""
self._cancel_callback = None
self._attr_is_on = False
self._active_alert = None
self.async_write_ha_state()
def _get_coordinator_alert(self) -> RingEvent | None:
return self.coordinator.alerts.get(
(self._device.device_api_id, self.entity_description.key)
)
@callback
def _handle_coordinator_update(self) -> None:
if alert := self._get_coordinator_alert():
self._async_handle_event(alert)
super()._handle_coordinator_update()
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self._active_alert is not None
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.event_listener.started
async def async_update(self) -> None:
"""All updates are passive."""
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
@ -127,9 +157,9 @@ class RingBinarySensor(
return attrs
assert isinstance(attrs, dict)
attrs["state"] = self._active_alert["state"]
now = self._active_alert.get("now")
expires_in = self._active_alert.get("expires_in")
attrs["state"] = self._active_alert.state
now = self._active_alert.now
expires_in = self._active_alert.expires_in
assert now and expires_in
attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat()

View File

@ -8,17 +8,12 @@ from ring_doorbell import Auth, AuthenticationError, Requires2FAError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
APPLICATION_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
__version__ as ha_version,
)
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import get_auth_agent_id
from .const import CONF_2FA, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -32,9 +27,11 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
user_agent, hardware_id = await get_auth_agent_id(hass)
auth = Auth(
f"{APPLICATION_NAME}/{ha_version}",
user_agent,
http_client_session=async_get_clientsession(hass),
hardware_id=hardware_id,
)
try:

View File

@ -26,6 +26,6 @@ PLATFORMS = [
SCAN_INTERVAL = timedelta(minutes=1)
NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5)
CONF_2FA = "2fa"
CONF_LISTEN_CREDENTIALS = "listen_token"

View File

@ -3,15 +3,28 @@
from asyncio import TaskGroup
from collections.abc import Callable, Coroutine
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout
from ring_doorbell import (
AuthenticationError,
Ring,
RingDevices,
RingError,
RingEvent,
RingTimeout,
)
from ring_doorbell.listen import RingEventListener
from homeassistant.core import HomeAssistant
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
from .const import SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
@ -91,19 +104,112 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
return devices
class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Global notifications coordinator."""
def __init__(self, hass: HomeAssistant, ring_api: Ring) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name="active dings",
update_interval=NOTIFICATIONS_SCAN_INTERVAL,
)
self.ring_api: Ring = ring_api
config_entry: config_entries.ConfigEntry
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await _call_api(self.hass, self.ring_api.async_update_dings)
def __init__(
self,
hass: HomeAssistant,
ring_api: Ring,
listen_credentials: dict[str, Any] | None,
listen_credentials_updater: Callable[[dict[str, Any]], None],
) -> None:
"""Initialize my coordinator."""
self.hass = hass
self.logger = _LOGGER
self.ring_api: Ring = ring_api
self.event_listener = RingEventListener(
ring_api, listen_credentials, listen_credentials_updater
)
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)
self.index_alerts()
def index_alerts(self) -> None:
"Index the active alerts."
self.alerts = {
(alert.doorbot_id, alert.kind): alert
for alert in self.ring_api.active_alerts()
}
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")
await 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.")
await self.event_listener.start(
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
)
self.index_alerts()
# Update the listeners so they switch from Unavailable to Unknown
self._async_update_listeners()
def _on_event(self, event: RingEvent) -> None:
self.logger.debug("Ring event received: %s", event)
self.index_alerts()
self._async_update_listeners(event.doorbot_id)
@callback
def _async_update_listeners(self, doorbot_id: int | None = None) -> None:
"""Update all registered listeners."""
for update_callback, device_api_id in list(self._listeners.values()):
if not doorbot_id or device_api_id == doorbot_id:
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

View File

@ -1,6 +1,7 @@
"""Base class for Ring entity."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Concatenate, Generic, cast
from ring_doorbell import (
@ -12,22 +13,46 @@ from ring_doorbell import (
)
from typing_extensions import TypeVar
from homeassistant.core import callback
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
CoordinatorEntity,
)
from .const import ATTRIBUTION, DOMAIN
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
from .coordinator import RingDataCoordinator, RingListenCoordinator
RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric)
_RingCoordinatorT = TypeVar(
"_RingCoordinatorT",
bound=(RingDataCoordinator | RingNotificationsCoordinator),
bound=(RingDataCoordinator | RingListenCoordinator),
)
@dataclass(slots=True)
class DeprecatedInfo:
"""Class to define deprecation info for deprecated entities."""
new_platform: Platform
breaks_in_ha_version: str
@dataclass(frozen=True, kw_only=True)
class RingEntityDescription(EntityDescription):
"""Base class for a ring entity description."""
deprecated_info: DeprecatedInfo | None = None
def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R](
async_func: Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]]:
@ -51,8 +76,66 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R](
return _wrap
def async_check_create_deprecated(
hass: HomeAssistant,
platform: Platform,
unique_id: str,
entity_description: RingEntityDescription,
) -> bool:
"""Return true if the entitty should be created based on the deprecated_info.
If deprecated_info is not defined will return true.
If entity not yet created will return false.
If entity disabled will delete it and return false.
Otherwise will return true and create issues for scripts or automations.
"""
if not entity_description.deprecated_info:
return True
ent_reg = er.async_get(hass)
entity_id = ent_reg.async_get_entity_id(
platform,
DOMAIN,
unique_id,
)
if not entity_id:
return False
entity_entry = ent_reg.async_get(entity_id)
assert entity_entry
if entity_entry.disabled:
# If the entity exists and is disabled then we want to remove
# the entity so that the user is just using the new entity.
ent_reg.async_remove(entity_id)
return False
# Check for issues that need to be created
entity_automations = automations_with_entity(hass, entity_id)
entity_scripts = scripts_with_entity(hass, entity_id)
if entity_automations or entity_scripts:
deprecated_info = entity_description.deprecated_info
for item in entity_automations + entity_scripts:
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{entity_id}_{item}",
breaks_in_ha_version=deprecated_info.breaks_in_ha_version,
is_fixable=False,
is_persistent=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"entity": entity_id,
"info": item,
"platform": platform,
"new_platform": deprecated_info.new_platform,
},
)
return True
class RingBaseEntity(
CoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT]
BaseCoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT]
):
"""Base implementation for Ring device."""
@ -77,7 +160,7 @@ class RingBaseEntity(
)
class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT]):
class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT], CoordinatorEntity):
"""Implementation for Ring devices."""
def _get_coordinator_data(self) -> RingDevices:

View File

@ -25,6 +25,7 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -32,7 +33,13 @@ from homeassistant.helpers.typing import StateType
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
from .entity import RingDeviceT, RingEntity
from .entity import (
DeprecatedInfo,
RingDeviceT,
RingEntity,
RingEntityDescription,
async_check_create_deprecated,
)
async def async_setup_entry(
@ -49,6 +56,12 @@ async def async_setup_entry(
for description in SENSOR_TYPES
for device in ring_data.devices.all_devices
if description.exists_fn(device)
and async_check_create_deprecated(
hass,
Platform.SENSOR,
f"{device.id}-{description.key}",
description,
)
]
async_add_entities(entities)
@ -120,7 +133,9 @@ def _get_last_event_attrs(
@dataclass(frozen=True, kw_only=True)
class RingSensorEntityDescription(SensorEntityDescription, Generic[RingDeviceT]):
class RingSensorEntityDescription(
SensorEntityDescription, RingEntityDescription, Generic[RingDeviceT]
):
"""Describes Ring sensor entity."""
value_fn: Callable[[RingDeviceT], StateType] = lambda _: True
@ -172,6 +187,9 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
)
else None,
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
deprecated_info=DeprecatedInfo(
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
),
),
RingSensorEntityDescription[RingGeneric](
key="last_motion",
@ -188,6 +206,9 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
)
else None,
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
deprecated_info=DeprecatedInfo(
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
),
),
RingSensorEntityDescription[RingDoorBell | RingChime](
key="volume",

View File

@ -35,6 +35,17 @@
"binary_sensor": {
"ding": {
"name": "Ding"
},
"motion": {
"name": "Motion"
}
},
"event": {
"ding": {
"name": "Ding"
},
"intercom_unlock": {
"name": "Intercom unlock"
}
},
"button": {
@ -104,6 +115,10 @@
}
}
}
},
"deprecated_entity": {
"title": "Detected deprecated `{platform}` entity usage",
"description": "We detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new `{new_platform}` entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated `{entity}` entity removed, disable the entity and restart Home Assistant."
}
}
}

View File

@ -2,6 +2,7 @@
from unittest.mock import patch
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.ring import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@ -18,3 +19,18 @@ async def setup_platform(hass: HomeAssistant, platform: Platform) -> None:
with patch("homeassistant.components.ring.PLATFORMS", [platform]):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done(wait_background_tasks=True)
async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None:
"""Set up an automation for tests."""
assert await async_setup_component(
hass,
AUTOMATION_DOMAIN,
{
AUTOMATION_DOMAIN: {
"alias": alias,
"trigger": {"platform": "state", "entity_id": entity_id, "to": "on"},
"action": {"action": "notify.notify", "metadata": {}, "data": {}},
}
},
)

View File

@ -11,7 +11,7 @@ from homeassistant.components.ring import DOMAIN
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from .device_mocks import get_active_alerts, get_devices_data, get_mock_devices
from .device_mocks import get_devices_data, get_mock_devices
from tests.common import MockConfigEntry
from tests.components.light.conftest import mock_light_profiles # noqa: F401
@ -103,7 +103,7 @@ def mock_ring_client(mock_ring_auth, mock_ring_devices):
mock_client = create_autospec(ring_doorbell.Ring)
mock_client.return_value.devices_data = get_devices_data()
mock_client.return_value.devices.return_value = mock_ring_devices
mock_client.return_value.active_alerts.side_effect = get_active_alerts
mock_client.return_value.active_alerts.return_value = []
with patch("homeassistant.components.ring.Ring", new=mock_client):
yield mock_client.return_value
@ -135,3 +135,14 @@ 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
@pytest.fixture(autouse=True)
def mock_ring_event_listener_class():
"""Fixture to mock the ring event listener."""
with patch(
"homeassistant.components.ring.coordinator.RingEventListener", autospec=True
) as mock_ring_listener:
mock_ring_listener.return_value.started = True
yield mock_ring_listener

View File

@ -7,9 +7,7 @@ Each device entry in the devices.json will have a MagicMock instead of the RingO
Mocks the api calls on the devices such as history() and health().
"""
from copy import deepcopy
from datetime import datetime
from time import time
from unittest.mock import AsyncMock, MagicMock
from ring_doorbell import (
@ -30,7 +28,10 @@ DOORBOT_HISTORY = load_json_value_fixture("doorbot_history.json", DOMAIN)
INTERCOM_HISTORY = load_json_value_fixture("intercom_history.json", DOMAIN)
DOORBOT_HEALTH = load_json_value_fixture("doorbot_health_attrs.json", DOMAIN)
CHIME_HEALTH = load_json_value_fixture("chime_health_attrs.json", DOMAIN)
DEVICE_ALERTS = load_json_value_fixture("ding_active.json", DOMAIN)
FRONT_DOOR_DEVICE_ID = 987654
INGRESS_DEVICE_ID = 185036587
FRONT_DEVICE_ID = 765432
def get_mock_devices():
@ -54,14 +55,6 @@ def get_devices_data():
}
def get_active_alerts():
"""Return active alerts set to now."""
dings_fixture = deepcopy(DEVICE_ALERTS)
for ding in dings_fixture:
ding["now"] = time()
return dings_fixture
DEVICE_TYPES = {
"doorbots": RingDoorBell,
"authorized_doorbots": RingDoorBell,
@ -76,6 +69,7 @@ DEVICE_CAPABILITIES = {
RingCapability.VOLUME,
RingCapability.MOTION_DETECTION,
RingCapability.VIDEO,
RingCapability.DING,
RingCapability.HISTORY,
],
RingStickUpCam: [
@ -88,7 +82,7 @@ DEVICE_CAPABILITIES = {
RingCapability.LIGHT,
],
RingChime: [RingCapability.VOLUME],
RingOther: [RingCapability.OPEN, RingCapability.HISTORY],
RingOther: [RingCapability.OPEN, RingCapability.HISTORY, RingCapability.DING],
}

View File

@ -1,24 +1,196 @@
"""The tests for the Ring binary sensor platform."""
from homeassistant.const import Platform
import time
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from ring_doorbell import Ring
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.ring.binary_sensor import RingEvent
from homeassistant.components.ring.const import DOMAIN
from homeassistant.components.ring.coordinator import RingEventListener
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.setup import async_setup_component
from .common import setup_platform
from .common import setup_automation
from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID
from tests.common import async_fire_time_changed
async def test_binary_sensor(hass: HomeAssistant, mock_ring_client) -> None:
@pytest.mark.parametrize(
("device_id", "device_name", "alert_kind", "device_class"),
[
pytest.param(
FRONT_DOOR_DEVICE_ID,
"front_door",
"motion",
"motion",
id="front_door_motion",
),
pytest.param(
FRONT_DOOR_DEVICE_ID,
"front_door",
"ding",
"occupancy",
id="front_door_ding",
),
pytest.param(
INGRESS_DEVICE_ID, "ingress", "ding", "occupancy", id="ingress_ding"
),
],
)
async def test_binary_sensor(
hass: HomeAssistant,
mock_config_entry: ConfigEntry,
mock_ring_client: Ring,
mock_ring_event_listener_class: RingEventListener,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
device_id: int,
device_name: str,
alert_kind: str,
device_class: str,
) -> None:
"""Test the Ring binary sensors."""
await setup_platform(hass, Platform.BINARY_SENSOR)
# Create the entity so it is not ignored by the deprecation check
mock_config_entry.add_to_hass(hass)
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"
entity_id = f"binary_sensor.{device_name}_{alert_kind}"
unique_id = f"{device_id}-{alert_kind}"
front_ding_state = hass.states.get("binary_sensor.front_door_ding")
assert front_ding_state is not None
assert front_ding_state.state == "off"
entity_registry.async_get_or_create(
domain=BINARY_SENSOR_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id=f"{device_name}_{alert_kind}",
config_entry=mock_config_entry,
)
with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]):
assert await async_setup_component(hass, DOMAIN, {})
ingress_ding_state = hass.states.get("binary_sensor.ingress_ding")
assert ingress_ding_state is not None
assert ingress_ding_state.state == "off"
on_event_cb = mock_ring_event_listener_class.return_value.add_notification_callback.call_args.args[
0
]
# Default state is set to off
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
assert state.attributes["device_class"] == device_class
# A new alert sets to on
event = RingEvent(
1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None
)
mock_ring_client.active_alerts.return_value = [event]
on_event_cb(event)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
# Test that another event resets the expiry callback
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
event = RingEvent(
1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None
)
mock_ring_client.active_alerts.return_value = [event]
on_event_cb(event)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
freezer.tick(120)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
# Test the second alert has expired
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
async def test_binary_sensor_not_exists_with_deprecation(
hass: HomeAssistant,
mock_config_entry: ConfigEntry,
mock_ring_client: Ring,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the deprecated Ring binary sensors are deleted or raise issues."""
mock_config_entry.add_to_hass(hass)
entity_id = "binary_sensor.front_door_motion"
assert not hass.states.get(entity_id)
with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]):
assert await async_setup_component(hass, DOMAIN, {})
assert not entity_registry.async_get(entity_id)
assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert not hass.states.get(entity_id)
@pytest.mark.parametrize(
("entity_disabled", "entity_has_automations"),
[
pytest.param(False, False, id="without-automations"),
pytest.param(False, True, id="with-automations"),
pytest.param(True, False, id="disabled"),
],
)
async def test_binary_sensor_exists_with_deprecation(
hass: HomeAssistant,
mock_config_entry: ConfigEntry,
mock_ring_client: Ring,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_disabled: bool,
entity_has_automations: bool,
) -> None:
"""Test the deprecated Ring binary sensors are deleted or raise issues."""
mock_config_entry.add_to_hass(hass)
entity_id = "binary_sensor.front_door_motion"
unique_id = f"{FRONT_DOOR_DEVICE_ID}-motion"
issue_id = f"deprecated_entity_{entity_id}_automation.test_automation"
if entity_has_automations:
await setup_automation(hass, "test_automation", entity_id)
entity = entity_registry.async_get_or_create(
domain=BINARY_SENSOR_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id="front_door_motion",
config_entry=mock_config_entry,
disabled_by=er.RegistryEntryDisabler.USER if entity_disabled else None,
)
assert entity.entity_id == entity_id
assert not hass.states.get(entity_id)
with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]):
assert await async_setup_component(hass, DOMAIN, {})
entity = entity_registry.async_get(entity_id)
# entity and state will be none if removed from registry
assert (entity is None) == entity_disabled
assert (hass.states.get(entity_id) is None) == entity_disabled
assert (
issue_registry.async_get_issue(DOMAIN, issue_id) is not None
) == entity_has_automations

View File

@ -2,19 +2,23 @@
from freezegun.api import FrozenDateTimeFactory
import pytest
from ring_doorbell import AuthenticationError, RingError, RingTimeout
from ring_doorbell import AuthenticationError, Ring, RingError, RingTimeout
from homeassistant.components import ring
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.ring import DOMAIN
from homeassistant.components.ring.const import SCAN_INTERVAL
from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL
from homeassistant.components.ring.coordinator import RingEventListener
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.setup import async_setup_component
from .device_mocks import FRONT_DOOR_DEVICE_ID
from tests.common import MockConfigEntry, async_fire_time_changed
@ -413,3 +417,63 @@ 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_listen_token_updated(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_ring_client,
mock_ring_event_listener_class,
) -> None:
"""Test that the listener token value is updated in the config entry.
This simulates the api calling the callback.
"""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_ring_event_listener_class.call_count == 1
token_updater = mock_ring_event_listener_class.call_args.args[2]
assert mock_config_entry.data.get(CONF_LISTEN_CREDENTIALS) is None
token_updater({"listen_access_token": "mock-token"})
assert mock_config_entry.data.get(CONF_LISTEN_CREDENTIALS) == {
"listen_access_token": "mock-token"
}
async def test_no_listen_start(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
entity_registry: er.EntityRegistry,
mock_ring_event_listener_class: type[RingEventListener],
mock_ring_client: Ring,
) -> None:
"""Test behaviour if listener doesn't start."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
version=1,
data={"username": "foo", "token": {}},
)
# Create a binary sensor entity so it is not ignored by the deprecation check
# and the listener will start
entity_registry.async_get_or_create(
domain=BINARY_SENSOR_DOMAIN,
platform=DOMAIN,
unique_id=f"{FRONT_DOOR_DEVICE_ID}-motion",
suggested_object_id=f"{FRONT_DOOR_DEVICE_ID}_motion",
config_entry=mock_entry,
)
mock_ring_event_listener_class.do_not_start = True
mock_ring_event_listener_class.return_value.started = False
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"
]

View File

@ -1,17 +1,26 @@
"""The tests for the Ring sensor platform."""
import logging
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from ring_doorbell import Ring
from homeassistant.components.ring.const import SCAN_INTERVAL
from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass
from homeassistant.components.ring.const import DOMAIN, SCAN_INTERVAL
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .common import setup_platform
from .device_mocks import FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID
from tests.common import async_fire_time_changed
@ -107,13 +116,23 @@ async def test_health_sensor(
@pytest.mark.parametrize(
("device_name", "sensor_name", "expected_value"),
("device_id", "device_name", "sensor_name", "expected_value"),
[
("front_door", "last_motion", "2017-03-05T15:03:40+00:00"),
("front_door", "last_ding", "2018-03-05T15:03:40+00:00"),
("front_door", "last_activity", "2018-03-05T15:03:40+00:00"),
("front", "last_motion", "2017-03-05T15:03:40+00:00"),
("ingress", "last_activity", "2024-02-02T11:21:24+00:00"),
(
FRONT_DOOR_DEVICE_ID,
"front_door",
"last_motion",
"2017-03-05T15:03:40+00:00",
),
(FRONT_DOOR_DEVICE_ID, "front_door", "last_ding", "2018-03-05T15:03:40+00:00"),
(
FRONT_DOOR_DEVICE_ID,
"front_door",
"last_activity",
"2018-03-05T15:03:40+00:00",
),
(FRONT_DEVICE_ID, "front", "last_motion", "2017-03-05T15:03:40+00:00"),
(INGRESS_DEVICE_ID, "ingress", "last_activity", "2024-02-02T11:21:24+00:00"),
],
ids=[
"doorbell-motion",
@ -125,14 +144,31 @@ async def test_health_sensor(
)
async def test_history_sensor(
hass: HomeAssistant,
mock_ring_client,
mock_ring_client: Ring,
mock_config_entry: ConfigEntry,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
device_name,
sensor_name,
expected_value,
device_id: int,
device_name: str,
sensor_name: str,
expected_value: str,
) -> None:
"""Test the Ring sensors."""
await setup_platform(hass, "sensor")
# Create the entity so it is not ignored by the deprecation check
mock_config_entry.add_to_hass(hass)
entity_id = f"sensor.{device_name}_{sensor_name}"
unique_id = f"{device_id}-{sensor_name}"
entity_registry.async_get_or_create(
domain=SENSOR_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id=f"{device_name}_{sensor_name}",
config_entry=mock_config_entry,
)
with patch("homeassistant.components.ring.PLATFORMS", [Platform.SENSOR]):
assert await async_setup_component(hass, DOMAIN, {})
entity_id = f"sensor.{device_name}_{sensor_name}"
sensor_state = hass.states.get(entity_id)