Migrate Unifi Protect last tripped time attributes to their own entities (#68347)

This commit is contained in:
J. Nick Koston 2022-03-24 14:23:53 -10:00 committed by GitHub
parent 9a396c1d16
commit 63ca0e70be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 139 additions and 42 deletions

View File

@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -27,7 +27,6 @@ from .entity import (
async_all_device_entities, async_all_device_entities,
) )
from .models import ProtectRequiredKeysMixin from .models import ProtectRequiredKeysMixin
from .utils import get_nested_attr
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_KEY_DOOR = "door" _KEY_DOOR = "door"
@ -39,8 +38,6 @@ class ProtectBinaryEntityDescription(
): ):
"""Describes UniFi Protect Binary Sensor entity.""" """Describes UniFi Protect Binary Sensor entity."""
ufp_last_trip_value: str | None = None
MOUNT_DEVICE_CLASS_MAP = { MOUNT_DEVICE_CLASS_MAP = {
MountType.GARAGE: BinarySensorDeviceClass.GARAGE_DOOR, MountType.GARAGE: BinarySensorDeviceClass.GARAGE_DOOR,
@ -57,7 +54,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
icon="mdi:doorbell-video", icon="mdi:doorbell-video",
ufp_required_field="feature_flags.has_chime", ufp_required_field="feature_flags.has_chime",
ufp_value="is_ringing", ufp_value="is_ringing",
ufp_last_trip_value="last_ring",
), ),
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
key="dark", key="dark",
@ -79,7 +75,6 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
name="Motion Detected", name="Motion Detected",
device_class=BinarySensorDeviceClass.MOTION, device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_pir_motion_detected", ufp_value="is_pir_motion_detected",
ufp_last_trip_value="last_motion",
), ),
) )
@ -89,7 +84,6 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
name="Contact", name="Contact",
device_class=BinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR,
ufp_value="is_opened", ufp_value="is_opened",
ufp_last_trip_value="open_status_changed_at",
ufp_enabled="is_contact_sensor_enabled", ufp_enabled="is_contact_sensor_enabled",
), ),
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
@ -104,7 +98,6 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
name="Motion Detected", name="Motion Detected",
device_class=BinarySensorDeviceClass.MOTION, device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_motion_detected", ufp_value="is_motion_detected",
ufp_last_trip_value="motion_detected_at",
ufp_enabled="is_motion_sensor_enabled", ufp_enabled="is_motion_sensor_enabled",
), ),
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
@ -112,7 +105,6 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
name="Tampering Detected", name="Tampering Detected",
device_class=BinarySensorDeviceClass.TAMPER, device_class=BinarySensorDeviceClass.TAMPER,
ufp_value="is_tampering_detected", ufp_value="is_tampering_detected",
ufp_last_trip_value="tampering_detected_at",
), ),
) )
@ -122,7 +114,6 @@ MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
name="Motion", name="Motion",
device_class=BinarySensorDeviceClass.MOTION, device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_motion_detected", 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() super()._async_update_device_from_protect()
self._attr_is_on = self.entity_description.get_ufp_value(self.device) 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 # UP Sense can be any of the 3 contact sensor device classes
if self.entity_description.key == _KEY_DOOR and isinstance(self.device, Sensor): if self.entity_description.key == _KEY_DOOR and isinstance(self.device, Sensor):
self.entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( self.entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get(

View File

@ -186,6 +186,15 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ufp_required_field="voltage", ufp_required_field="voltage",
precision=2, 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, ...] = ( CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
@ -252,6 +261,27 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ufp_value_fn=_get_alarm_sound, ufp_value_fn=_get_alarm_sound,
ufp_enabled="is_alarm_sensor_enabled", 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, ...] = ( 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
@ -412,6 +463,7 @@ async def async_setup_entry(
all_descs=ALL_DEVICES_SENSORS, all_descs=ALL_DEVICES_SENSORS,
camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS,
sense_descs=SENSE_SENSORS, sense_descs=SENSE_SENSORS,
light_descs=LIGHT_SENSORS,
lock_descs=DOORLOCK_SENSORS, lock_descs=DOORLOCK_SENSORS,
) )
entities += _async_motion_entities(data) entities += _async_motion_entities(data)
@ -426,6 +478,14 @@ def _async_motion_entities(
) -> list[ProtectDeviceEntity]: ) -> list[ProtectDeviceEntity]:
entities: list[ProtectDeviceEntity] = [] entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values(): 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: if not device.feature_flags.has_smart_detect:
continue continue

View File

@ -24,7 +24,6 @@ from homeassistant.components.unifiprotect.const import (
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_LAST_TRIP_TIME,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
@ -230,7 +229,7 @@ async def test_binary_sensor_setup_light(
entity_registry = er.async_get(hass) 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( unique_id, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, light, description Platform.BINARY_SENSOR, light, description
) )
@ -244,9 +243,6 @@ async def test_binary_sensor_setup_light(
assert state.state == STATE_OFF assert state.state == STATE_OFF
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION 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( async def test_binary_sensor_setup_camera_all(
hass: HomeAssistant, camera: Camera, now: datetime 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.state == STATE_OFF
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1)
# Is Dark # Is Dark
description = CAMERA_SENSORS[1] description = CAMERA_SENSORS[1]
unique_id, entity_id = ids_from_device_description( 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) entity_registry = er.async_get(hass)
expected_trip_time = now - timedelta(hours=1) for description in SENSE_SENSORS:
for index, description in enumerate(SENSE_SENSORS):
unique_id, entity_id = ids_from_device_description( unique_id, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, sensor, description Platform.BINARY_SENSOR, sensor, description
) )
@ -348,9 +341,6 @@ async def test_binary_sensor_setup_sensor(
assert state.state == STATE_OFF assert state.state == STATE_OFF
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION 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( async def test_binary_sensor_setup_sensor_none(
hass: HomeAssistant, sensor_none: Sensor hass: HomeAssistant, sensor_none: Sensor

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from copy import copy from copy import copy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest.mock import Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
from pyunifiprotect.data import NVR, Camera, Event, Sensor from pyunifiprotect.data import NVR, Camera, Event, Sensor
@ -21,6 +21,7 @@ from homeassistant.components.unifiprotect.sensor import (
CAMERA_DISABLED_SENSORS, CAMERA_DISABLED_SENSORS,
CAMERA_SENSORS, CAMERA_SENSORS,
MOTION_SENSORS, MOTION_SENSORS,
MOTION_TRIP_SENSORS,
NVR_DISABLED_SENSORS, NVR_DISABLED_SENSORS,
NVR_SENSORS, NVR_SENSORS,
OBJECT_TYPE_NONE, OBJECT_TYPE_NONE,
@ -78,9 +79,6 @@ async def sensor_fixture(
await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done() 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 yield sensor_obj
Sensor.__config__.validate_assignment = True 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.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# 2 from all, 4 from sense, 12 NVR # 4 from all, 5 from sense, 12 NVR
assert_entity_counts(hass, Platform.SENSOR, 19, 14) assert_entity_counts(hass, Platform.SENSOR, 22, 14)
yield sensor_obj yield sensor_obj
@ -144,6 +142,7 @@ async def camera_fixture(
camera_obj.channels[2]._api = mock_entry.api camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera" camera_obj.name = "Test Camera"
camera_obj.feature_flags.has_smart_detect = True camera_obj.feature_flags.has_smart_detect = True
camera_obj.feature_flags.has_chime = True
camera_obj.is_smart_detected = False camera_obj.is_smart_detected = False
camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000) camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000)
camera_obj.wifi_connection_state = WifiConnectionState( 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.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done() 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 yield camera_obj
Camera.__config__.validate_assignment = True Camera.__config__.validate_assignment = True
@ -178,11 +174,21 @@ async def test_sensor_setup_sensor(
hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor
): ):
"""Test sensor entity setup for sensor devices.""" """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) 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): for index, description in enumerate(SENSE_SENSORS):
if not description.entity_registry_enabled_default:
continue
unique_id, entity_id = ids_from_device_description( unique_id, entity_id = ids_from_device_description(
Platform.SENSOR, sensor, description Platform.SENSOR, sensor, description
) )
@ -229,6 +235,8 @@ async def test_sensor_setup_sensor_none(
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
) )
for index, description in enumerate(SENSE_SENSORS): for index, description in enumerate(SENSE_SENSORS):
if not description.entity_registry_enabled_default:
continue
unique_id, entity_id = ids_from_device_description( unique_id, entity_id = ids_from_device_description(
Platform.SENSOR, sensor_none, 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 hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime
): ):
"""Test sensor entity setup for camera devices.""" """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) entity_registry = er.async_get(hass)
@ -405,6 +415,8 @@ async def test_sensor_setup_camera(
"20.0", "20.0",
) )
for index, description in enumerate(CAMERA_SENSORS): for index, description in enumerate(CAMERA_SENSORS):
if not description.entity_registry_enabled_default:
continue
unique_id, entity_id = ids_from_device_description( unique_id, entity_id = ids_from_device_description(
Platform.SENSOR, camera, description Platform.SENSOR, camera, description
) )
@ -487,10 +499,37 @@ async def test_sensor_setup_camera(
assert state.attributes[ATTR_EVENT_SCORE] == 0 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( async def test_sensor_update_motion(
hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime
): ):
"""Test sensor motion entity.""" """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( _, entity_id = ids_from_device_description(
Platform.SENSOR, camera, MOTION_SENSORS[0] 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 hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor, now: datetime
): ):
"""Test sensor motion entity.""" """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( _, entity_id = ids_from_device_description(
Platform.SENSOR, sensor, SENSE_SENSORS[4] Platform.SENSOR, sensor, SENSE_SENSORS[4]
@ -571,3 +612,28 @@ async def test_sensor_update_alarm(
assert state assert state
assert state.state == "smoke" assert state.state == "smoke"
await time_changed(hass, 10) 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