diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index b56761263f4..ad251ba6153 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -61,6 +61,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.EVENT, Platform.LIGHT, Platform.LOCK, Platform.MEDIA_PLAYER, diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py new file mode 100644 index 00000000000..4e2fd7fce44 --- /dev/null +++ b/homeassistant/components/unifiprotect/event.py @@ -0,0 +1,102 @@ +"""Platform providing event entities for UniFi Protect.""" + +from __future__ import annotations + +import dataclasses + +from uiprotect.data import ( + Camera, + EventType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_EVENT_ID +from .data import ProtectData, UFPConfigEntry +from .entity import EventEntityMixin, ProtectDeviceEntity +from .models import ProtectEventMixin + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription): + """Describes UniFi Protect event entity.""" + + +EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( + ProtectEventEntityDescription( + key="doorbell", + translation_key="doorbell", + name="Doorbell", + device_class=EventDeviceClass.DOORBELL, + icon="mdi:doorbell-video", + ufp_required_field="feature_flags.is_doorbell", + ufp_event_obj="last_ring_event", + event_types=[EventType.RING], + ), +) + + +class ProtectDeviceEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity): + """A UniFi Protect event entity.""" + + entity_description: ProtectEventEntityDescription + + @callback + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + description = self.entity_description + + prev_event = self._event + prev_event_end = self._event_end + super()._async_update_device_from_protect(device) + if event := description.get_event_obj(device): + self._event = event + self._event_end = event.end if event else None + + if ( + event + and not self._event_already_ended(prev_event, prev_event_end) + and (event_types := description.event_types) + and (event_type := event.type) in event_types + ): + self._trigger_event(event_type, {ATTR_EVENT_ID: event.id}) + self.async_write_ha_state() + + +@callback +def _async_event_entities( + data: ProtectData, + ufp_device: ProtectAdoptableDeviceModel | None = None, +) -> list[ProtectDeviceEntity]: + entities: list[ProtectDeviceEntity] = [] + for device in data.get_cameras() if ufp_device is None else [ufp_device]: + entities.extend( + ProtectDeviceEventEntity(data, device, description) + for description in EVENT_DESCRIPTIONS + if description.has_required(device) + ) + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up event entities for UniFi Protect integration.""" + data = entry.runtime_data + + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if device.is_adopted and isinstance(device, Camera): + async_add_entities(_async_event_entities(data, ufp_device=device)) + + data.async_subscribe_adopt(_add_new_device) + async_add_entities(_async_event_entities(data)) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 1435de5011e..f785498c005 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -137,6 +137,17 @@ "none": "Clear" } } + }, + "event": { + "doorbell": { + "state_attributes": { + "event_type": { + "state": { + "ring": "Ring" + } + } + } + } } }, "services": { diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py new file mode 100644 index 00000000000..9d1a701fe39 --- /dev/null +++ b/tests/components/unifiprotect/test_event.py @@ -0,0 +1,154 @@ +"""Test the UniFi Protect event platform.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import Mock + +from uiprotect.data import Camera, Event, EventType, ModelType, SmartDetectObjectType + +from homeassistant.components.unifiprotect.const import ( + ATTR_EVENT_ID, + DEFAULT_ATTRIBUTION, +) +from homeassistant.components.unifiprotect.event import EVENT_DESCRIPTIONS +from homeassistant.const import ATTR_ATTRIBUTION, Platform +from homeassistant.core import Event as HAEvent, HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change_event + +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + ids_from_device_description, + init_entry, + remove_entities, +) + + +async def test_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera +) -> None: + """Test removing and re-adding a camera device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 1, 1) + await remove_entities(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 1, 1) + + +async def test_doorbell_ring( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell ring event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 1, 1) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] + ) + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.RING, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.last_ring_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + timestamp = state.state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.RING, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=50, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # Event is already seen and has end, should now be off + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + # Now send an event that has an end right away + event = Event( + model=ModelType.EVENT, + id="new_event_id", + type=EventType.RING, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=80, + smart_detect_types=[SmartDetectObjectType.PACKAGE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + unsub()