diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index e57826fd2f3..9bda0e8f310 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -14,6 +14,7 @@ from uiprotect.data import ( ProtectAdoptableDeviceModel, ProtectModelWithId, Sensor, + SmartDetectObjectType, ) from uiprotect.data.nvr import UOSDisk @@ -436,11 +437,13 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_enabled="is_motion_detection_on", ufp_event_obj="last_motion_event", ), +) + +SMART_EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_any", name="Object detected", icon="mdi:eye", - ufp_value="is_smart_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", ), @@ -448,7 +451,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_person", name="Person detected", icon="mdi:walk", - ufp_value="is_person_currently_detected", + ufp_obj_type=SmartDetectObjectType.PERSON, ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", ufp_event_obj="last_person_detect_event", @@ -457,7 +460,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_vehicle", name="Vehicle detected", icon="mdi:car", - ufp_value="is_vehicle_currently_detected", + ufp_obj_type=SmartDetectObjectType.VEHICLE, ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", ufp_event_obj="last_vehicle_detect_event", @@ -466,7 +469,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_animal", name="Animal detected", icon="mdi:paw", - ufp_value="is_animal_currently_detected", + ufp_obj_type=SmartDetectObjectType.ANIMAL, ufp_required_field="can_detect_animal", ufp_enabled="is_animal_detection_on", ufp_event_obj="last_animal_detect_event", @@ -475,8 +478,8 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_package", name="Package detected", icon="mdi:package-variant-closed", - ufp_value="is_package_currently_detected", entity_registry_enabled_default=False, + ufp_obj_type=SmartDetectObjectType.PACKAGE, ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", ufp_event_obj="last_package_detect_event", @@ -485,7 +488,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_any", name="Audio object detected", icon="mdi:eye", - ufp_value="is_audio_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_audio_detect_event", ), @@ -493,7 +495,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_smoke", name="Smoke alarm detected", icon="mdi:fire", - ufp_value="is_smoke_currently_detected", + ufp_obj_type=SmartDetectObjectType.SMOKE, ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", ufp_event_obj="last_smoke_detect_event", @@ -502,16 +504,16 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_cmonx", name="CO alarm detected", icon="mdi:molecule-co", - ufp_value="is_cmonx_currently_detected", ufp_required_field="can_detect_co", ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", + ufp_obj_type=SmartDetectObjectType.CMONX, ), ProtectBinaryEventEntityDescription( key="smart_audio_siren", name="Siren detected", icon="mdi:alarm-bell", - ufp_value="is_siren_currently_detected", + ufp_obj_type=SmartDetectObjectType.SIREN, ufp_required_field="can_detect_siren", ufp_enabled="is_siren_detection_on", ufp_event_obj="last_siren_detect_event", @@ -520,7 +522,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_baby_cry", name="Baby cry detected", icon="mdi:cradle", - ufp_value="is_baby_cry_currently_detected", + ufp_obj_type=SmartDetectObjectType.BABY_CRY, ufp_required_field="can_detect_baby_cry", ufp_enabled="is_baby_cry_detection_on", ufp_event_obj="last_baby_cry_detect_event", @@ -529,7 +531,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_speak", name="Speaking detected", icon="mdi:account-voice", - ufp_value="is_speaking_currently_detected", + ufp_obj_type=SmartDetectObjectType.SPEAK, ufp_required_field="can_detect_speaking", ufp_enabled="is_speaking_detection_on", ufp_event_obj="last_speaking_detect_event", @@ -538,7 +540,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_bark", name="Barking detected", icon="mdi:dog", - ufp_value="is_bark_currently_detected", + ufp_obj_type=SmartDetectObjectType.BARK, ufp_required_field="can_detect_bark", ufp_enabled="is_bark_detection_on", ufp_event_obj="last_bark_detect_event", @@ -547,7 +549,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_car_alarm", name="Car alarm detected", icon="mdi:car", - ufp_value="is_car_alarm_currently_detected", + ufp_obj_type=SmartDetectObjectType.BURGLAR, ufp_required_field="can_detect_car_alarm", ufp_enabled="is_car_alarm_detection_on", ufp_event_obj="last_car_alarm_detect_event", @@ -556,7 +558,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_car_horn", name="Car horn detected", icon="mdi:bugle", - ufp_value="is_car_horn_currently_detected", + ufp_obj_type=SmartDetectObjectType.CAR_HORN, ufp_required_field="can_detect_car_horn", ufp_enabled="is_car_horn_detection_on", ufp_event_obj="last_car_horn_detect_event", @@ -565,7 +567,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_glass_break", name="Glass break detected", icon="mdi:glass-fragile", - ufp_value="last_glass_break_detect", + ufp_obj_type=SmartDetectObjectType.GLASS_BREAK, ufp_required_field="can_detect_glass_break", ufp_enabled="is_glass_break_detection_on", ufp_event_obj="last_glass_break_detect_event", @@ -709,11 +711,50 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - is_on = self.entity_description.get_is_on(self.device, self._event) - self._attr_is_on = is_on - if not is_on: - self._event = None + description = self.entity_description + event = self._event = self.entity_description.get_event_obj(device) + if is_on := bool(description.get_ufp_value(device)): + if event: + self._set_event_attrs(event) + else: self._attr_extra_state_attributes = {} + self._attr_is_on = is_on + + +class ProtectSmartEventBinarySensor(EventEntityMixin, BinarySensorEntity): + """A UniFi Protect Device Binary Sensor for smart events.""" + + device: Camera + entity_description: ProtectBinaryEventEntityDescription + _state_attrs = ("_attr_available", "_attr_is_on", "_attr_extra_state_attributes") + + @callback + def _set_event_done(self) -> None: + self._attr_is_on = False + self._attr_extra_state_attributes = {} + + @callback + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + prev_event = self._event + super()._async_update_device_from_protect(device) + description = self.entity_description + self._event = description.get_event_obj(device) + + if not ( + (event := self._event) + and not self._event_already_ended(prev_event) + and description.has_matching_smart(event) + and ((is_end := event.end) or self.device.is_smart_detected) + ): + self._set_event_done() + return + + was_on = self._attr_is_on + self._attr_is_on = True + self._set_event_attrs(event) + + if is_end and not was_on: + self._async_event_with_immediate_end() MODEL_DESCRIPTIONS_WITH_CLASS = ( @@ -727,12 +768,19 @@ def _async_event_entities( data: ProtectData, ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: - return [ - ProtectEventBinarySensor(data, device, description) - for device in (data.get_cameras() if ufp_device is None else [ufp_device]) - for description in EVENT_SENSORS - if description.has_required(device) - ] + entities: list[ProtectDeviceEntity] = [] + for device in data.get_cameras() if ufp_device is None else [ufp_device]: + entities.extend( + ProtectSmartEventBinarySensor(data, device, description) + for description in SMART_EVENT_SENSORS + if description.has_required(device) + ) + entities.extend( + ProtectEventBinarySensor(data, device, description) + for description in EVENT_SENSORS + if description.has_required(device) + ) + return entities @callback diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index adf0d334e0a..3777338209b 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -305,13 +305,27 @@ class EventEntityMixin(ProtectDeviceEntity): _event: Event | None = None @callback - def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: - if (event := self.entity_description.get_event_obj(device)) is None: - self._attr_extra_state_attributes = {} - else: - self._attr_extra_state_attributes = { - ATTR_EVENT_ID: event.id, - ATTR_EVENT_SCORE: event.score, - } - self._event = event - super()._async_update_device_from_protect(device) + def _set_event_done(self) -> None: + """Clear the event and state.""" + + @callback + def _set_event_attrs(self, event: Event) -> None: + """Set event attrs.""" + self._attr_extra_state_attributes = { + ATTR_EVENT_ID: event.id, + ATTR_EVENT_SCORE: event.score, + } + + @callback + def _async_event_with_immediate_end(self) -> None: + # If the event is so short that the detection is received + # in the same message as the end of the event we need to write + # state and than clear the event and write state again. + self.async_write_ha_state() + self._set_event_done() + self.async_write_ha_state() + + @callback + def _event_already_ended(self, prev_event: Event | None) -> bool: + event = self._event + return bool(event and event.end and prev_event and prev_event.id == event.id) diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index fc24ddaa6e3..3bd2416b550 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -10,7 +10,12 @@ import logging from operator import attrgetter from typing import Any, Generic, TypeVar -from uiprotect.data import NVR, Event, ProtectAdoptableDeviceModel +from uiprotect.data import ( + NVR, + Event, + ProtectAdoptableDeviceModel, + SmartDetectObjectType, +) from homeassistant.helpers.entity import EntityDescription @@ -79,21 +84,24 @@ class ProtectEventMixin(ProtectEntityDescription[T]): """Mixin for events.""" ufp_event_obj: str | None = None + ufp_obj_type: SmartDetectObjectType | None = None def get_event_obj(self, obj: T) -> Event | None: """Return value from UniFi Protect device.""" return None + def has_matching_smart(self, event: Event) -> bool: + """Determine if the detection type is a match.""" + return ( + not (obj_type := self.ufp_obj_type) or obj_type in event.smart_detect_types + ) + def __post_init__(self) -> None: """Override get_event_obj if ufp_event_obj is set.""" if (_ufp_event_obj := self.ufp_event_obj) is not None: object.__setattr__(self, "get_event_obj", attrgetter(_ufp_event_obj)) super().__post_init__() - def get_is_on(self, obj: T, event: Event | None) -> bool: - """Return value if event is active.""" - return event is not None and self.get_ufp_value(obj) - @dataclass(frozen=True, kw_only=True) class ProtectSetableKeysMixin(ProtectEntityDescription[T]): diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index e166d532dfb..ccd341088ef 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -18,6 +18,7 @@ from uiprotect.data import ( ProtectDeviceModel, ProtectModelWithId, Sensor, + SmartDetectObjectType, ) from homeassistant.components.sensor import ( @@ -542,7 +543,7 @@ LICENSE_PLATE_EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( name="License plate detected", icon="mdi:car", translation_key="license_plate", - ufp_value="is_license_plate_currently_detected", + ufp_obj_type=SmartDetectObjectType.LICENSE_PLATE, ufp_required_field="can_detect_license_plate", ufp_event_obj="last_license_plate_detect_event", ), @@ -747,19 +748,34 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): class ProtectLicensePlateEventSensor(ProtectEventSensor): """A UniFi Protect license plate sensor.""" + device: Camera + + @callback + def _set_event_done(self) -> None: + self._attr_native_value = OBJECT_TYPE_NONE + self._attr_extra_state_attributes = {} + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + prev_event = self._event super()._async_update_device_from_protect(device) - event = self._event - entity_description = self.entity_description - if ( - event is None - or (event.metadata is None or event.metadata.license_plate is None) - or not entity_description.get_is_on(self.device, event) + description = self.entity_description + self._event = description.get_event_obj(device) + + if not ( + (event := self._event) + and not self._event_already_ended(prev_event) + and description.has_matching_smart(event) + and ((is_end := event.end) or self.device.is_smart_detected) + and (metadata := event.metadata) + and (license_plate := metadata.license_plate) ): - self._attr_native_value = OBJECT_TYPE_NONE - self._event = None - self._attr_extra_state_attributes = {} + self._set_event_done() return - self._attr_native_value = event.metadata.license_plate.name + previous_plate = self._attr_native_value + self._attr_native_value = license_plate.name + self._set_event_attrs(event) + + if is_end and previous_plate != license_plate.name: + self._async_event_with_immediate_end() diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 02a1ce3f421..6366a4f9244 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -217,6 +217,7 @@ def doorbell_fixture(camera: Camera, fixed_now: datetime): SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, SmartDetectObjectType.ANIMAL, + SmartDetectObjectType.PACKAGE, ] doorbell.has_speaker = True doorbell.feature_flags.has_hdr = True diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 4674ec289ca..51fb882144f 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -5,7 +5,17 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from uiprotect.data import Camera, Event, EventType, Light, ModelType, MountType, Sensor +import pytest +from uiprotect.data import ( + Camera, + Event, + EventType, + Light, + ModelType, + MountType, + Sensor, + SmartDetectObjectType, +) from uiprotect.data.nvr import EventMetadata from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -15,6 +25,7 @@ from homeassistant.components.unifiprotect.binary_sensor import ( LIGHT_SENSORS, MOUNTABLE_SENSE_SENSORS, SENSE_SENSORS, + SMART_EVENT_SENSORS, ) from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_SCORE, @@ -23,12 +34,13 @@ from homeassistant.components.unifiprotect.const import ( from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event as HAEvent, EventStateChangedData, HomeAssistant from homeassistant.helpers import entity_registry as er from .utils import ( @@ -40,6 +52,8 @@ from .utils import ( remove_entities, ) +from tests.common import async_capture_events + LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] SENSE_SENSORS_WRITE = SENSE_SENSORS[:3] @@ -51,11 +65,11 @@ async def test_binary_sensor_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 8) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 8) async def test_binary_sensor_light_remove( @@ -123,7 +137,7 @@ async def test_binary_sensor_setup_camera_all( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 8) description = EVENT_SENSORS[0] unique_id, entity_id = ids_from_device_description( @@ -273,7 +287,7 @@ async def test_binary_sensor_update_motion( """Test binary_sensor motion entity.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 14) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 14) _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] @@ -421,3 +435,144 @@ async def test_binary_sensor_update_mount_type_garage( assert ( state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GARAGE_DOOR.value ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_package_detected( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test binary_sensor package detection entity.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 15) + + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PACKAGE) + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, doorbell, SMART_EVENT_SENSORS[4] + ) + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[SmartDetectObjectType.PACKAGE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = 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() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 100 + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=50, + smart_detect_types=[SmartDetectObjectType.PACKAGE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = 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() + + # Event is already seen and has end, should now be off + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # Now send an event that has an end right away + event = Event( + model=ModelType.EVENT, + id="new_event_id", + type=EventType.SMART_DETECT, + 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() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = 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 + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + assert len(state_changes) == 2 + + on_event = state_changes[0] + state = on_event.data["new_state"] + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 80 + + off_event = state_changes[1] + state = off_event.data["new_state"] + assert state + assert state.state == STATE_OFF + assert ATTR_EVENT_SCORE not in state.attributes + + # replay and ensure ignored + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index ac631ee41a6..b3842be4e0a 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -30,11 +30,12 @@ from homeassistant.components.unifiprotect.sensor import ( ) from homeassistant.const import ( ATTR_ATTRIBUTION, + EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event as HAEvent, EventStateChangedData, HomeAssistant from homeassistant.helpers import entity_registry as er from .utils import ( @@ -49,6 +50,8 @@ from .utils import ( time_changed, ) +from tests.common import async_capture_events + CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] @@ -554,6 +557,10 @@ async def test_camera_update_license_plate( ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) ufp.ws_msg(mock_msg) await hass.async_block_till_done() @@ -561,6 +568,63 @@ async def test_camera_update_license_plate( assert state assert state.state == "ABCD1234" + assert len(state_changes) == 1 + + # ensure reply is ignored + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=100, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + ufp.api.bootstrap.events = {event.id: event} + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + # Now send a new event with end already set + event = Event( + model=ModelType.EVENT, + id="new_event", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=100, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + ufp.api.bootstrap.events = {event.id: event} + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 4 + assert state_changes[2].data["new_state"].state == "ABCD1234" + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + async def test_sensor_precision( hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index da16475dc1c..6e5c83ef237 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -59,11 +59,11 @@ async def test_switch_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.SWITCH, 2, 2) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) async def test_switch_light_remove( @@ -175,7 +175,7 @@ async def test_switch_setup_camera_all( """Test switch entity setup for camera devices (all enabled feature flags).""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) for description in CAMERA_SWITCHES_BASIC: unique_id, entity_id = ids_from_device_description( @@ -295,7 +295,7 @@ async def test_switch_camera_ssh( """Tests SSH switch for cameras.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = CAMERA_SWITCHES[0] @@ -328,7 +328,7 @@ async def test_switch_camera_simple( """Tests all simple switches for cameras.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) assert description.ufp_set_method is not None @@ -357,7 +357,7 @@ async def test_switch_camera_highfps( """Tests High FPS switch for cameras.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = CAMERA_SWITCHES[3] @@ -388,7 +388,7 @@ async def test_switch_camera_privacy( previous_record = doorbell.recording_settings.mode = RecordingMode.DETECTIONS await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = PRIVACY_MODE_SWITCH @@ -440,7 +440,7 @@ async def test_switch_camera_privacy_already_on( doorbell.add_privacy_zone() await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 16, 14) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = PRIVACY_MODE_SWITCH