Split UniFi Protect object sensor into multiple (#82595)

This commit is contained in:
Christopher Bailey 2022-11-28 14:07:53 -05:00 committed by GitHub
parent 892be99ca0
commit b842e26d36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 274 additions and 87 deletions

View file

@ -8,13 +8,13 @@ import logging
from pyunifiprotect.data import (
NVR,
Camera,
Event,
Light,
ModelType,
MountType,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
Sensor,
SmartDetectObjectType,
)
from pyunifiprotect.data.nvr import UOSDisk
@ -29,15 +29,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DISPATCH_ADOPT, DOMAIN
from .const import DEVICE_CLASS_DETECTION, DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import (
EventThumbnailMixin,
EventEntityMixin,
ProtectDeviceEntity,
ProtectNVREntity,
async_all_device_entities,
)
from .models import PermRequired, ProtectRequiredKeysMixin
from .models import PermRequired, ProtectEventMixin, ProtectRequiredKeysMixin
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
@ -51,6 +51,13 @@ class ProtectBinaryEntityDescription(
"""Describes UniFi Protect Binary Sensor entity."""
@dataclass
class ProtectBinaryEventEntityDescription(
ProtectEventMixin, BinarySensorEntityDescription
):
"""Describes UniFi Protect Binary Sensor entity."""
MOUNT_DEVICE_CLASS_MAP = {
MountType.GARAGE: BinarySensorDeviceClass.GARAGE_DOOR,
MountType.WINDOW: BinarySensorDeviceClass.WINDOW,
@ -179,7 +186,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key="smart_face",
name="Detections: Face",
icon="mdi:human-greeting",
icon="mdi:mdi-face",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="can_detect_face",
ufp_value="is_face_detection_on",
@ -313,12 +320,66 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
),
)
MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
MOTION_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ProtectBinaryEventEntityDescription(
key="motion",
name="Motion",
device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_motion_detected",
ufp_event_obj="last_motion_event",
),
ProtectBinaryEventEntityDescription(
key="smart_obj_any",
name="Object Detected",
icon="mdi:eye",
device_class=DEVICE_CLASS_DETECTION,
ufp_value="is_smart_detected",
ufp_required_field="feature_flags.has_smart_detect",
ufp_event_obj="last_smart_detect_event",
),
ProtectBinaryEventEntityDescription(
key="smart_obj_person",
name="Person Detected",
icon="mdi:walk",
device_class=DEVICE_CLASS_DETECTION,
ufp_value="is_smart_detected",
ufp_required_field="can_detect_person",
ufp_enabled="is_person_detection_on",
ufp_event_obj="last_smart_detect_event",
ufp_smart_type=SmartDetectObjectType.PERSON,
),
ProtectBinaryEventEntityDescription(
key="smart_obj_vehicle",
name="Vehicle Detected",
icon="mdi:car",
device_class=DEVICE_CLASS_DETECTION,
ufp_value="is_smart_detected",
ufp_required_field="can_detect_vehicle",
ufp_enabled="is_vehicle_detection_on",
ufp_event_obj="last_smart_detect_event",
ufp_smart_type=SmartDetectObjectType.VEHICLE,
),
ProtectBinaryEventEntityDescription(
key="smart_obj_face",
name="Face Detected",
device_class=DEVICE_CLASS_DETECTION,
icon="mdi:mdi-face",
ufp_value="is_smart_detected",
ufp_required_field="can_detect_face",
ufp_enabled="is_face_detection_on",
ufp_event_obj="last_smart_detect_event",
ufp_smart_type=SmartDetectObjectType.FACE,
),
ProtectBinaryEventEntityDescription(
key="smart_obj_package",
name="Package Detected",
device_class=DEVICE_CLASS_DETECTION,
icon="mdi:package-variant-closed",
ufp_value="is_smart_detected",
ufp_required_field="can_detect_package",
ufp_enabled="is_package_detection_on",
ufp_event_obj="last_smart_detect_event",
ufp_smart_type=SmartDetectObjectType.PACKAGE,
),
)
@ -415,6 +476,8 @@ def _async_motion_entities(
)
for device in devices:
for description in MOTION_SENSORS:
if not description.has_required(device):
continue
entities.append(ProtectEventBinarySensor(data, device, description))
_LOGGER.debug(
"Adding binary sensor entity %s for %s",
@ -508,17 +571,12 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
self._attr_is_on = not self._disk.is_healthy
class ProtectEventBinarySensor(EventThumbnailMixin, ProtectDeviceBinarySensor):
"""A UniFi Protect Device Binary Sensor with access tokens."""
class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
"""A UniFi Protect Device Binary Sensor for events."""
device: Camera
entity_description: ProtectBinaryEventEntityDescription
@callback
def _async_get_event(self) -> Event | None:
"""Get event from Protect device."""
event: Event | None = None
if self.device.is_motion_detected and self.device.last_motion_event is not None:
event = self.device.last_motion_event
return event
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None:
super()._async_update_device_from_protect(device)
self._attr_is_on = self.entity_description.get_ufp_value(self.device)

View file

@ -7,6 +7,7 @@ from homeassistant.const import Platform
DOMAIN = "unifiprotect"
ATTR_EVENT_SCORE = "event_score"
ATTR_EVENT_ID = "event_id"
ATTR_WIDTH = "width"
ATTR_HEIGHT = "height"
ATTR_FPS = "fps"
@ -67,3 +68,5 @@ PLATFORMS = [
DISPATCH_ADD = "add_device"
DISPATCH_ADOPT = "adopt_device"
DISPATCH_CHANNELS = "new_camera_channels"
DEVICE_CLASS_DETECTION = "unifiprotect__detection"

View file

@ -24,10 +24,15 @@ from homeassistant.core import callback
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
from .const import (
ATTR_EVENT_ID,
ATTR_EVENT_SCORE,
DEFAULT_ATTRIBUTION,
DEFAULT_BRAND,
DOMAIN,
)
from .data import ProtectData
from .models import PermRequired, ProtectRequiredKeysMixin
from .utils import get_nested_attr
from .models import PermRequired, ProtectEventMixin, ProtectRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
@ -82,10 +87,8 @@ def _async_device_entities(
):
continue
if description.ufp_required_field:
required_field = get_nested_attr(device, description.ufp_required_field)
if not required_field:
continue
if not description.has_required(device):
continue
entities.append(
klass(
@ -294,42 +297,39 @@ class ProtectNVREntity(ProtectDeviceEntity):
self._attr_available = self.data.last_update_success
class EventThumbnailMixin(ProtectDeviceEntity):
class EventEntityMixin(ProtectDeviceEntity):
"""Adds motion event attributes to sensor."""
def __init__(self, *args: Any, **kwarg: Any) -> None:
entity_description: ProtectEventMixin
def __init__(
self,
*args: Any,
**kwarg: Any,
) -> None:
"""Init an sensor that has event thumbnails."""
super().__init__(*args, **kwarg)
self._event: Event | None = None
@callback
def _async_get_event(self) -> Event | None:
"""Get event from Protect device.
To be overridden by child classes.
"""
raise NotImplementedError()
@callback
def _async_thumbnail_extra_attrs(self) -> dict[str, Any]:
# Camera motion sensors with object detection
attrs: dict[str, Any] = {
ATTR_EVENT_SCORE: 0,
}
def _async_event_extra_attrs(self) -> dict[str, Any]:
attrs: dict[str, Any] = {}
if self._event is None:
return attrs
attrs[ATTR_EVENT_ID] = self._event.id
attrs[ATTR_EVENT_SCORE] = self._event.score
return attrs
@callback
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None:
super()._async_update_device_from_protect(device)
self._event = self._async_get_event()
self._attr_is_on: bool | None = self.entity_description.get_is_on(device)
self._event = self.entity_description.get_event_obj(device)
attrs = self.extra_state_attributes or {}
self._attr_extra_state_attributes = {
**attrs,
**self._async_thumbnail_extra_attrs(),
**self._async_event_extra_attrs(),
}

View file

@ -12,7 +12,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -30,6 +33,23 @@ async def async_migrate_data(
await async_migrate_device_ids(hass, entry, protect)
_LOGGER.debug("Completed Migrate: async_migrate_device_ids")
entity_registry = er.async_get(hass)
for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id):
if (
entity.domain == Platform.SENSOR
and entity.disabled_by is None
and "detected_object" in entity.unique_id
):
ir.async_create_issue(
hass,
DOMAIN,
"deprecate_smart_sensor",
is_fixable=False,
breaks_in_ha_version="2023.2.0",
severity=IssueSeverity.WARNING,
translation_key="deprecate_smart_sensor",
)
async def async_get_bootstrap(protect: ProtectApiClient) -> Bootstrap:
"""Get UniFi Protect bootstrap or raise appropriate HA error."""

View file

@ -5,9 +5,9 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from enum import Enum
import logging
from typing import Any, Generic, TypeVar, Union
from typing import Any, Generic, TypeVar, Union, cast
from pyunifiprotect.data import NVR, ProtectAdoptableDeviceModel
from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel
from homeassistant.helpers.entity import EntityDescription
@ -54,6 +54,41 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]):
return bool(get_nested_attr(obj, self.ufp_enabled))
return True
def has_required(self, obj: T) -> bool:
"""Return if has required field."""
if self.ufp_required_field is None:
return True
return bool(get_nested_attr(obj, self.ufp_required_field))
@dataclass
class ProtectEventMixin(ProtectRequiredKeysMixin[T]):
"""Mixin for events."""
ufp_event_obj: str | None = None
ufp_smart_type: str | None = None
def get_event_obj(self, obj: T) -> Event | None:
"""Return value from UniFi Protect device."""
if self.ufp_event_obj is not None:
return cast(Event, get_nested_attr(obj, self.ufp_event_obj))
return None
def get_is_on(self, obj: T) -> bool:
"""Return value if event is active."""
value = bool(self.get_ufp_value(obj))
if value:
event = self.get_event_obj(obj)
value = event is not None
if event is not None and self.ufp_smart_type is not None:
value = self.ufp_smart_type in event.smart_detect_types
return value
@dataclass
class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]):

View file

@ -9,7 +9,6 @@ from typing import Any, cast
from pyunifiprotect.data import (
NVR,
Camera,
Event,
Light,
ModelType,
ProtectAdoptableDeviceModel,
@ -41,20 +40,19 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DISPATCH_ADOPT, DOMAIN
from .const import DEVICE_CLASS_DETECTION, DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import (
EventThumbnailMixin,
EventEntityMixin,
ProtectDeviceEntity,
ProtectNVREntity,
async_all_device_entities,
)
from .models import PermRequired, ProtectRequiredKeysMixin, T
from .models import PermRequired, ProtectEventMixin, ProtectRequiredKeysMixin, T
from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current
_LOGGER = logging.getLogger(__name__)
OBJECT_TYPE_NONE = "none"
DEVICE_CLASS_DETECTION = "unifiprotect__detection"
@dataclass
@ -74,6 +72,13 @@ class ProtectSensorEntityDescription(
return value
@dataclass
class ProtectSensorEventEntityDescription(
ProtectEventMixin[T], SensorEntityDescription
):
"""Describes UniFi Protect Sensor entity."""
def _get_uptime(obj: ProtectDeviceModel) -> datetime | None:
if obj.up_since is None:
return None
@ -513,11 +518,14 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
),
)
MOTION_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription(
MOTION_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = (
ProtectSensorEventEntityDescription(
key="detected_object",
name="Detected Object",
device_class=DEVICE_CLASS_DETECTION,
entity_registry_enabled_default=False,
ufp_value="is_smart_detected",
ufp_event_obj="last_smart_detect_event",
),
)
@ -666,8 +674,8 @@ def _async_motion_entities(
if not device.feature_flags.has_smart_detect:
continue
for description in MOTION_SENSORS:
entities.append(ProtectEventSensor(data, device, description))
for event_desc in MOTION_SENSORS:
entities.append(ProtectEventSensor(data, device, event_desc))
_LOGGER.debug(
"Adding sensor entity %s for %s",
description.name,
@ -730,29 +738,24 @@ class ProtectNVRSensor(ProtectNVREntity, SensorEntity):
self._attr_native_value = self.entity_description.get_ufp_value(self.device)
class ProtectEventSensor(ProtectDeviceSensor, EventThumbnailMixin):
class ProtectEventSensor(EventEntityMixin, SensorEntity):
"""A UniFi Protect Device Sensor with access tokens."""
device: Camera
entity_description: ProtectSensorEventEntityDescription
@callback
def _async_get_event(self) -> Event | None:
"""Get event from Protect device."""
event: Event | None = None
if (
self.device.is_smart_detected
and self.device.last_smart_detect_event is not None
and len(self.device.last_smart_detect_event.smart_detect_types) > 0
):
event = self.device.last_smart_detect_event
return event
def __init__(
self,
data: ProtectData,
device: ProtectAdoptableDeviceModel,
description: ProtectSensorEventEntityDescription,
) -> None:
"""Initialize an UniFi Protect sensor."""
super().__init__(data, device, description)
@callback
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None:
# do not call ProtectDeviceSensor method since we want event to get value here
EventThumbnailMixin._async_update_device_from_protect(self, device)
EventEntityMixin._async_update_device_from_protect(self, device)
if self._event is None:
self._attr_native_value = OBJECT_TYPE_NONE
else:

View file

@ -75,6 +75,10 @@
"ea_setup_failed": {
"title": "Setup error using Early Access version",
"description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please [downgrade to a stable version](https://www.home-assistant.io/integrations/unifiprotect#downgrading-unifi-protect) of UniFi Protect to continue using the integration.\n\nError: {error}"
},
"deprecate_smart_sensor": {
"title": "Smart Detection Sensor Deprecated",
"description": "The unified \"Detected Object\" sensor for smart detections is now deprecated. It has been replaced with individual smart detection binary sensors for each smart detection type. Please update any templates or automations accordingly."
}
}
}

View file

@ -42,6 +42,10 @@
}
},
"issues": {
"deprecate_smart_sensor": {
"description": "The unified \"Detected Object\" sensor for smart detections is now deprecated. It has been replaced with individual smart detection binary sensors for each smart detection type. Please update any templates or automations accordingly.",
"title": "Smart Detection Sensor Deprecated"
},
"ea_setup_failed": {
"description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please [downgrade to a stable version](https://www.home-assistant.io/integrations/unifiprotect#downgrading-unifi-protect) of UniFi Protect to continue using the integration.\n\nError: {error}",
"title": "Setup error using Early Access version"

View file

@ -50,11 +50,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, 3, 3)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 6, 6)
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, 3, 3)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 6, 6)
async def test_binary_sensor_light_remove(
@ -120,7 +120,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, 3, 3)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 6, 6)
entity_registry = er.async_get(hass)
@ -167,7 +167,6 @@ async def test_binary_sensor_setup_camera_all(
assert state
assert state.state == STATE_OFF
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_SCORE] == 0
async def test_binary_sensor_setup_camera_none(
@ -263,7 +262,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, 9, 9)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 12, 12)
_, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, doorbell, MOTION_SENSORS[0]

View file

@ -6,7 +6,7 @@ from copy import copy
from http import HTTPStatus
from unittest.mock import Mock
from pyunifiprotect.data import Version
from pyunifiprotect.data import Camera, Version
from homeassistant.components.repairs.issue_handler import (
async_process_repairs_platforms,
@ -16,7 +16,9 @@ from homeassistant.components.repairs.websocket_api import (
RepairsFlowResourceView,
)
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .utils import MockUFPFixture, init_entry
@ -40,9 +42,12 @@ async def test_ea_warning_ignore(
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
issue = msg["result"]["issues"][0]
assert issue["issue_id"] == "ea_warning"
assert len(msg["result"]["issues"]) > 0
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "ea_warning":
issue = i
assert issue is not None
url = RepairsFlowIndexView.url
resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "ea_warning"})
@ -89,9 +94,12 @@ async def test_ea_warning_fix(
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
issue = msg["result"]["issues"][0]
assert issue["issue_id"] == "ea_warning"
assert len(msg["result"]["issues"]) > 0
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "ea_warning":
issue = i
assert issue is not None
url = RepairsFlowIndexView.url
resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "ea_warning"})
@ -118,3 +126,53 @@ async def test_ea_warning_fix(
data = await resp.json()
assert data["type"] == "create_entry"
async def test_deprecate_smart_default(
hass: HomeAssistant, ufp: MockUFPFixture, hass_ws_client, doorbell: Camera
):
"""Test Deprecate Sensor repair does not exist by default (new installs)."""
await init_entry(hass, ufp, [doorbell])
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "deprecate_smart_sensor":
issue = i
assert issue is None
async def test_deprecate_smart_active(
hass: HomeAssistant, ufp: MockUFPFixture, hass_ws_client, doorbell: Camera
):
"""Test Deprecate Sensor repair exists for existing installs."""
registry = er.async_get(hass)
registry.async_get_or_create(
Platform.SENSOR,
DOMAIN,
f"{doorbell.mac}_detected_object",
config_entry=ufp.entry,
)
await init_entry(hass, ufp, [doorbell])
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "deprecate_smart_sensor":
issue = i
assert issue is not None

View file

@ -62,11 +62,11 @@ async def test_sensor_camera_remove(
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SENSOR, 25, 13)
assert_entity_counts(hass, Platform.SENSOR, 25, 12)
await remove_entities(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SENSOR, 12, 9)
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SENSOR, 25, 13)
assert_entity_counts(hass, Platform.SENSOR, 25, 12)
async def test_sensor_sensor_remove(
@ -318,7 +318,7 @@ async def test_sensor_setup_camera(
"""Test sensor entity setup for camera devices."""
await init_entry(hass, ufp, [doorbell])
assert_entity_counts(hass, Platform.SENSOR, 25, 13)
assert_entity_counts(hass, Platform.SENSOR, 25, 12)
entity_registry = er.async_get(hass)
@ -406,11 +406,12 @@ async def test_sensor_setup_camera(
assert entity
assert entity.unique_id == unique_id
await enable_entity(hass, ufp.entry.entry_id, entity_id)
state = hass.states.get(entity_id)
assert state
assert state.state == OBJECT_TYPE_NONE
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_SCORE] == 0
async def test_sensor_setup_camera_with_last_trip_time(
@ -451,12 +452,14 @@ async def test_sensor_update_motion(
"""Test sensor motion entity."""
await init_entry(hass, ufp, [doorbell])
assert_entity_counts(hass, Platform.SENSOR, 25, 13)
assert_entity_counts(hass, Platform.SENSOR, 25, 12)
_, entity_id = ids_from_device_description(
Platform.SENSOR, doorbell, MOTION_SENSORS[0]
)
await enable_entity(hass, ufp.entry.entry_id, entity_id)
event = Event(
id="test_event_id",
type=EventType.SMART_DETECT,