mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
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:
parent
5405279273
commit
b3d6f8861f
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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:
|
||||
|
@ -26,6 +26,6 @@ PLATFORMS = [
|
||||
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
CONF_2FA = "2fa"
|
||||
CONF_LISTEN_CREDENTIALS = "listen_token"
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
]
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user