From 63ca0e70bec57a75563ebb237a257a056df74b8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Mar 2022 14:23:53 -1000 Subject: [PATCH] Migrate Unifi Protect last tripped time attributes to their own entities (#68347) --- .../components/unifiprotect/binary_sensor.py | 21 +---- .../components/unifiprotect/sensor.py | 60 +++++++++++++ .../unifiprotect/test_binary_sensor.py | 14 +-- tests/components/unifiprotect/test_sensor.py | 86 ++++++++++++++++--- 4 files changed, 139 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index a5b41a446fc..7613ffb8ebf 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LAST_TRIP_TIME, ATTR_MODEL +from homeassistant.const import ATTR_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,7 +27,6 @@ from .entity import ( async_all_device_entities, ) from .models import ProtectRequiredKeysMixin -from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" @@ -39,8 +38,6 @@ class ProtectBinaryEntityDescription( ): """Describes UniFi Protect Binary Sensor entity.""" - ufp_last_trip_value: str | None = None - MOUNT_DEVICE_CLASS_MAP = { MountType.GARAGE: BinarySensorDeviceClass.GARAGE_DOOR, @@ -57,7 +54,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( icon="mdi:doorbell-video", ufp_required_field="feature_flags.has_chime", ufp_value="is_ringing", - ufp_last_trip_value="last_ring", ), ProtectBinaryEntityDescription( key="dark", @@ -79,7 +75,6 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( name="Motion Detected", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_pir_motion_detected", - ufp_last_trip_value="last_motion", ), ) @@ -89,7 +84,6 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( name="Contact", device_class=BinarySensorDeviceClass.DOOR, ufp_value="is_opened", - ufp_last_trip_value="open_status_changed_at", ufp_enabled="is_contact_sensor_enabled", ), ProtectBinaryEntityDescription( @@ -104,7 +98,6 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( name="Motion Detected", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_motion_detected", - ufp_last_trip_value="motion_detected_at", ufp_enabled="is_motion_sensor_enabled", ), ProtectBinaryEntityDescription( @@ -112,7 +105,6 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( name="Tampering Detected", device_class=BinarySensorDeviceClass.TAMPER, ufp_value="is_tampering_detected", - ufp_last_trip_value="tampering_detected_at", ), ) @@ -122,7 +114,6 @@ MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( name="Motion", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_motion_detected", - ufp_last_trip_value="last_motion", ), ) @@ -215,16 +206,6 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): super()._async_update_device_from_protect() self._attr_is_on = self.entity_description.get_ufp_value(self.device) - if self.entity_description.ufp_last_trip_value is not None: - last_trip = get_nested_attr( - self.device, self.entity_description.ufp_last_trip_value - ) - attrs = self.extra_state_attributes or {} - self._attr_extra_state_attributes = { - **attrs, - ATTR_LAST_TRIP_TIME: last_trip, - } - # UP Sense can be any of the 3 contact sensor device classes if self.entity_description.key == _KEY_DOOR and isinstance(self.device, Sensor): self.entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 330c90880ab..b90688efc70 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -186,6 +186,15 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_required_field="voltage", precision=2, ), + ProtectSensorEntityDescription( + key="doorbell_last_trip_time", + name="Last Doorbell Ring", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:doorbell-video", + ufp_required_field="feature_flags.has_chime", + ufp_value="last_ring", + entity_registry_enabled_default=False, + ), ) CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -252,6 +261,27 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", ), + ProtectSensorEntityDescription( + key="door_last_trip_time", + name="Last Open", + device_class=SensorDeviceClass.TIMESTAMP, + ufp_value="open_status_changed_at", + entity_registry_enabled_default=False, + ), + ProtectSensorEntityDescription( + key="motion_last_trip_time", + name="Last Motion Detected", + device_class=SensorDeviceClass.TIMESTAMP, + ufp_value="motion_detected_at", + entity_registry_enabled_default=False, + ), + ProtectSensorEntityDescription( + key="tampering_last_trip_time", + name="Last Tampering Detected", + device_class=SensorDeviceClass.TIMESTAMP, + ufp_value="tampering_detected_at", + entity_registry_enabled_default=False, + ), ) DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -399,6 +429,27 @@ MOTION_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ) +LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( + ProtectSensorEntityDescription( + key="motion_last_trip_time", + name="Last Motion Detected", + device_class=SensorDeviceClass.TIMESTAMP, + ufp_value="last_motion", + entity_registry_enabled_default=False, + ), +) + +MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( + ProtectSensorEntityDescription( + key="motion_last_trip_time", + name="Last Motion Detected", + device_class=SensorDeviceClass.TIMESTAMP, + ufp_value="last_motion", + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -412,6 +463,7 @@ async def async_setup_entry( all_descs=ALL_DEVICES_SENSORS, camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, sense_descs=SENSE_SENSORS, + light_descs=LIGHT_SENSORS, lock_descs=DOORLOCK_SENSORS, ) entities += _async_motion_entities(data) @@ -426,6 +478,14 @@ def _async_motion_entities( ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] for device in data.api.bootstrap.cameras.values(): + for description in MOTION_TRIP_SENSORS: + entities.append(ProtectDeviceSensor(data, device, description)) + _LOGGER.debug( + "Adding trip sensor entity %s for %s", + description.name, + device.name, + ) + if not device.feature_flags.has_smart_detect: continue diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 88f19e59d7d..60a3d5a8126 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -24,7 +24,6 @@ from homeassistant.components.unifiprotect.const import ( from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, - ATTR_LAST_TRIP_TIME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -230,7 +229,7 @@ async def test_binary_sensor_setup_light( entity_registry = er.async_get(hass) - for index, description in enumerate(LIGHT_SENSORS): + for description in LIGHT_SENSORS: unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, light, description ) @@ -244,9 +243,6 @@ async def test_binary_sensor_setup_light( assert state.state == STATE_OFF assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - if index == 1: - assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1) - async def test_binary_sensor_setup_camera_all( hass: HomeAssistant, camera: Camera, now: datetime @@ -269,8 +265,6 @@ async def test_binary_sensor_setup_camera_all( assert state.state == STATE_OFF assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1) - # Is Dark description = CAMERA_SENSORS[1] unique_id, entity_id = ids_from_device_description( @@ -333,8 +327,7 @@ async def test_binary_sensor_setup_sensor( entity_registry = er.async_get(hass) - expected_trip_time = now - timedelta(hours=1) - for index, description in enumerate(SENSE_SENSORS): + for description in SENSE_SENSORS: unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor, description ) @@ -348,9 +341,6 @@ async def test_binary_sensor_setup_sensor( assert state.state == STATE_OFF assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - if index != 1: - assert state.attributes[ATTR_LAST_TRIP_TIME] == expected_trip_time - async def test_binary_sensor_setup_sensor_none( hass: HomeAssistant, sensor_none: Sensor diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 1f5624c30a9..5b14ef3f2fb 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from copy import copy from datetime import datetime, timedelta -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest from pyunifiprotect.data import NVR, Camera, Event, Sensor @@ -21,6 +21,7 @@ from homeassistant.components.unifiprotect.sensor import ( CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, MOTION_SENSORS, + MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, OBJECT_TYPE_NONE, @@ -78,9 +79,6 @@ async def sensor_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - # 2 from all, 4 from sense, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 19, 14) - yield sensor_obj Sensor.__config__.validate_assignment = True @@ -117,8 +115,8 @@ async def sensor_none_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - # 2 from all, 4 from sense, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 19, 14) + # 4 from all, 5 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 22, 14) yield sensor_obj @@ -144,6 +142,7 @@ async def camera_fixture( camera_obj.channels[2]._api = mock_entry.api camera_obj.name = "Test Camera" camera_obj.feature_flags.has_smart_detect = True + camera_obj.feature_flags.has_chime = True camera_obj.is_smart_detected = False camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000) camera_obj.wifi_connection_state = WifiConnectionState( @@ -166,9 +165,6 @@ async def camera_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - # 3 from all, 6 from camera, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 22, 14) - yield camera_obj Camera.__config__.validate_assignment = True @@ -178,11 +174,21 @@ async def test_sensor_setup_sensor( hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor ): """Test sensor entity setup for sensor devices.""" + # 5 from all, 5 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 22, 14) entity_registry = er.async_get(hass) - expected_values = ("10", "10.0", "10.0", "10.0", "none") + expected_values = ( + "10", + "10.0", + "10.0", + "10.0", + "none", + ) for index, description in enumerate(SENSE_SENSORS): + if not description.entity_registry_enabled_default: + continue unique_id, entity_id = ids_from_device_description( Platform.SENSOR, sensor, description ) @@ -229,6 +235,8 @@ async def test_sensor_setup_sensor_none( STATE_UNAVAILABLE, ) for index, description in enumerate(SENSE_SENSORS): + if not description.entity_registry_enabled_default: + continue unique_id, entity_id = ids_from_device_description( Platform.SENSOR, sensor_none, description ) @@ -395,6 +403,8 @@ async def test_sensor_setup_camera( hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime ): """Test sensor entity setup for camera devices.""" + # 3 from all, 8 from camera, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 24, 14) entity_registry = er.async_get(hass) @@ -405,6 +415,8 @@ async def test_sensor_setup_camera( "20.0", ) for index, description in enumerate(CAMERA_SENSORS): + if not description.entity_registry_enabled_default: + continue unique_id, entity_id = ids_from_device_description( Platform.SENSOR, camera, description ) @@ -487,10 +499,37 @@ async def test_sensor_setup_camera( assert state.attributes[ATTR_EVENT_SCORE] == 0 +async def test_sensor_setup_camera_with_last_trip_time( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + mock_entry: MockEntityFixture, + camera: Camera, + now: datetime, +): + """Test sensor entity setup for camera devices with last trip time.""" + entity_registry = er.async_get(hass) + + # Last Trip Time + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, camera, MOTION_TRIP_SENSORS[0] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == "2021-12-20T17:26:53+00:00" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + async def test_sensor_update_motion( hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime ): """Test sensor motion entity.""" + # 3 from all, 8 from camera, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 24, 14) _, entity_id = ids_from_device_description( Platform.SENSOR, camera, MOTION_SENSORS[0] @@ -534,6 +573,8 @@ async def test_sensor_update_alarm( hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor, now: datetime ): """Test sensor motion entity.""" + # 5 from all, 5 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 22, 14) _, entity_id = ids_from_device_description( Platform.SENSOR, sensor, SENSE_SENSORS[4] @@ -571,3 +612,28 @@ async def test_sensor_update_alarm( assert state assert state.state == "smoke" await time_changed(hass, 10) + + +async def test_sensor_update_alarm_with_last_trip_time( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + mock_entry: MockEntityFixture, + sensor: Sensor, + now: datetime, +): + """Test sensor motion entity with last trip time.""" + + # Last Trip Time + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor, SENSE_SENSORS[-3] + ) + entity_registry = er.async_get(hass) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == "2022-01-04T04:03:56+00:00" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION