diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index c67adbf5984..5fac77d63bb 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SIREN, diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py new file mode 100644 index 00000000000..e6d9d25542f --- /dev/null +++ b/homeassistant/components/ring/event.py @@ -0,0 +1,109 @@ +"""Component providing support for ring events.""" + +from dataclasses import dataclass +from typing import Generic + +from ring_doorbell import RingCapability, RingEvent as RingAlert +from ring_doorbell.const import KIND_DING, KIND_INTERCOM_UNLOCK, KIND_MOTION + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RingConfigEntry +from .coordinator import RingListenCoordinator +from .entity import RingBaseEntity, RingDeviceT + + +@dataclass(frozen=True, kw_only=True) +class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): + """Base class for event entity description.""" + + capability: RingCapability + + +EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = ( + RingEventEntityDescription( + key=KIND_DING, + translation_key=KIND_DING, + device_class=EventDeviceClass.DOORBELL, + event_types=[KIND_DING], + capability=RingCapability.DING, + ), + RingEventEntityDescription( + key=KIND_MOTION, + translation_key=KIND_MOTION, + device_class=EventDeviceClass.MOTION, + event_types=[KIND_MOTION], + capability=RingCapability.MOTION_DETECTION, + ), + RingEventEntityDescription( + key=KIND_INTERCOM_UNLOCK, + translation_key=KIND_INTERCOM_UNLOCK, + device_class=EventDeviceClass.BUTTON, + event_types=[KIND_INTERCOM_UNLOCK], + capability=RingCapability.OPEN, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RingConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up events for a Ring device.""" + ring_data = entry.runtime_data + listen_coordinator = ring_data.listen_coordinator + + async_add_entities( + RingEvent(device, listen_coordinator, description) + for description in EVENT_DESCRIPTIONS + for device in ring_data.devices.all_devices + if device.has_capability(description.capability) + ) + + +class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity): + """An event implementation for Ring device.""" + + entity_description: RingEventEntityDescription[RingDeviceT] + + def __init__( + self, + device: RingDeviceT, + coordinator: RingListenCoordinator, + description: RingEventEntityDescription[RingDeviceT], + ) -> None: + """Initialize a event entity for Ring device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{device.id}-{description.key}" + + @callback + def _async_handle_event(self, event: str) -> None: + """Handle the event.""" + self._trigger_event(event) + + def _get_coordinator_alert(self) -> RingAlert | 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.kind) + super()._handle_coordinator_update() + + @property + 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.""" diff --git a/tests/components/ring/test_event.py b/tests/components/ring/test_event.py new file mode 100644 index 00000000000..c546f9ea136 --- /dev/null +++ b/tests/components/ring/test_event.py @@ -0,0 +1,80 @@ +"""The tests for the Ring event platform.""" + +from datetime import datetime +import time + +from freezegun.api import FrozenDateTimeFactory +import pytest +from ring_doorbell import Ring + +from homeassistant.components.ring.binary_sensor import RingEvent +from homeassistant.components.ring.coordinator import RingEventListener +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .common import setup_platform +from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID + + +@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", "doorbell", id="front_door_ding" + ), + pytest.param( + INGRESS_DEVICE_ID, "ingress", "ding", "doorbell", id="ingress_ding" + ), + pytest.param( + INGRESS_DEVICE_ID, + "ingress", + "intercom_unlock", + "button", + id="ingress_unlock", + ), + ], +) +async def test_event( + hass: HomeAssistant, + mock_ring_client: Ring, + mock_ring_event_listener_class: RingEventListener, + freezer: FrozenDateTimeFactory, + device_id: int, + device_name: str, + alert_kind: str, + device_class: str, +) -> None: + """Test the Ring event platforms.""" + + await setup_platform(hass, Platform.EVENT) + + start_time_str = "2024-09-04T15:32:53.892+00:00" + start_time = datetime.strptime(start_time_str, "%Y-%m-%dT%H:%M:%S.%f%z") + freezer.move_to(start_time) + on_event_cb = mock_ring_event_listener_class.return_value.add_notification_callback.call_args.args[ + 0 + ] + + # Default state is unknown + entity_id = f"event.{device_name}_{alert_kind}" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" + 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 == start_time_str