mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
646 lines
24 KiB
Python
646 lines
24 KiB
Python
"""Platform providing event entities for UniFi Protect."""
|
|
|
|
import dataclasses
|
|
import re
|
|
from typing import Any, override
|
|
|
|
from uiprotect import ProtectEvent
|
|
from uiprotect.data import ModelType, SmartDetectObjectType
|
|
from uiprotect.data.nvr import Event, EventDetectedThumbnail
|
|
|
|
from homeassistant.components.event import (
|
|
DoorbellEventType,
|
|
EventDeviceClass,
|
|
EventEntity,
|
|
EventEntityDescription,
|
|
)
|
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
from homeassistant.helpers.event import async_call_at
|
|
|
|
from . import Bootstrap
|
|
from .const import (
|
|
ATTR_EVENT_ID,
|
|
ATTR_SMART_DETECT_TYPES,
|
|
EVENT_TYPE_FINGERPRINT_IDENTIFIED,
|
|
EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
|
|
EVENT_TYPE_NFC_SCANNED,
|
|
EVENT_TYPE_PACKAGE_DETECTED,
|
|
EVENT_TYPE_VEHICLE_DETECTED,
|
|
KEYRINGS_KEY_TYPE_ID_NFC,
|
|
KEYRINGS_ULP_ID,
|
|
KEYRINGS_USER_FULL_NAME,
|
|
KEYRINGS_USER_STATUS,
|
|
VEHICLE_EVENT_DELAY_SECONDS,
|
|
)
|
|
from .data import (
|
|
EventType,
|
|
ProtectAdoptableDeviceModel,
|
|
ProtectData,
|
|
ProtectDeviceType,
|
|
UFPConfigEntry,
|
|
)
|
|
from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
# Per-entity cap on tracked event ids for fire dedup (far above realistic
|
|
# concurrent/recent events per camera per category).
|
|
_MAX_TRACKED_EVENTS = 16
|
|
|
|
|
|
# Select best thumbnail
|
|
# Prefer thumbnails with LPR data, sorted by confidence
|
|
# LPR can be in: 1) group.matched_name (UFP 6.0+) or 2) name field
|
|
def _thumbnail_sort_key(t: EventDetectedThumbnail) -> tuple[bool, float, float]:
|
|
"""Sort key: (has_lpr, confidence, clock_best_wall)."""
|
|
has_lpr = bool((t.group and t.group.matched_name) or (t.name and len(t.name) > 0))
|
|
confidence = t.confidence or 0.0
|
|
clock = t.clock_best_wall.timestamp() if t.clock_best_wall else 0.0
|
|
return (has_lpr, confidence, clock)
|
|
|
|
|
|
def _add_ulp_user_infos(
|
|
bootstrap: Bootstrap, event_data: dict[str, str], ulp_id: str
|
|
) -> None:
|
|
"""Add ULP user information to the event data."""
|
|
if ulp_usr := bootstrap.ulp_users.by_ulp_id(ulp_id):
|
|
event_data.update(
|
|
{
|
|
KEYRINGS_ULP_ID: ulp_usr.ulp_id,
|
|
KEYRINGS_USER_FULL_NAME: ulp_usr.full_name,
|
|
KEYRINGS_USER_STATUS: ulp_usr.status,
|
|
}
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription):
|
|
"""Describes UniFi Protect event entity."""
|
|
|
|
entity_class: type[ProtectDeviceEntity]
|
|
|
|
|
|
# Protect emits overlapping ``smartDetectZone``, ``smartDetectLine``, and
|
|
# ``smartDetectLoiterZone`` frames for the same underlying detection, and a
|
|
# line-crossing or loitering detection can arrive as a standalone event of its
|
|
# own type — all three carry the same ``smartDetectTypes`` payload per the
|
|
# public API schema, so smart-detect entities subscribe to all of them.
|
|
_SMART_DETECT_EVENT_TYPES = (
|
|
EventType.SMART_DETECT,
|
|
EventType.SMART_DETECT_LINE,
|
|
EventType.SMART_DETECT_LOITER,
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
class ProtectDetectionEventEntityDescription(ProtectEventEntityDescription):
|
|
"""Describes a category detection event entity driven by the public events WS."""
|
|
|
|
ufp_public_event_types: tuple[EventType, ...]
|
|
|
|
|
|
class ProtectDevicePublicEventEntity(
|
|
EventEntityMixin, ProtectDeviceEntity, EventEntity
|
|
):
|
|
"""Base for entities driven by the public events WS.
|
|
|
|
A detection type can surface at the event start, on a later update, or only
|
|
as the event ends, and every non-eviction change is dispatched — so firing is
|
|
deduped per ``(event id, event type)``.
|
|
|
|
Availability follows the public API (device present and connected) plus the
|
|
events websocket, which is the only channel these entities fire from.
|
|
"""
|
|
|
|
_ufp_uses_public = True
|
|
_ufp_requires_events_ws = True
|
|
|
|
entity_description: ProtectEventEntityDescription
|
|
# A camera can run two overlapping events of the same category whose
|
|
# dispatches interleave, so dedup tracks fired types per recent event id
|
|
# (bounded), not just the current one.
|
|
_fired: dict[str, frozenset[str]] | None = None
|
|
|
|
@callback
|
|
def _fire_once(
|
|
self, event: ProtectEvent, event_type: str, event_data: dict[str, Any]
|
|
) -> None:
|
|
"""Fire ``event_type`` once per event, ignoring repeat dispatches."""
|
|
fired = self._fired
|
|
if fired is None:
|
|
fired = self._fired = {}
|
|
# Pop-and-reinsert so any dispatch refreshes this event id's recency; a
|
|
# long-running event that keeps updating is then not evicted below.
|
|
types = fired.pop(event.id, frozenset())
|
|
if event_type in types:
|
|
fired[event.id] = types
|
|
return
|
|
fired[event.id] = types | {event_type}
|
|
if len(fired) > _MAX_TRACKED_EVENTS:
|
|
del fired[next(iter(fired))] # evict the least-recently-seen event id
|
|
self._trigger_event(event_type, event_data)
|
|
self.async_write_ha_state()
|
|
|
|
|
|
class ProtectDeviceRingEventEntity(ProtectDevicePublicEventEntity):
|
|
"""A UniFi Protect doorbell ring event entity driven by the public events WS."""
|
|
|
|
entity_description: ProtectEventEntityDescription
|
|
|
|
@override
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Subscribe to public ring events for this doorbell."""
|
|
await super().async_added_to_hass()
|
|
self.async_on_remove(
|
|
self.data.async_subscribe_public_event(
|
|
self.device.id, EventType.RING, self._async_ring_event
|
|
)
|
|
)
|
|
|
|
@callback
|
|
def _async_ring_event(self, event: ProtectEvent) -> None:
|
|
self._fire_once(event, DoorbellEventType.RING, {ATTR_EVENT_ID: event.id})
|
|
|
|
|
|
class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
|
|
"""A UniFi Protect NFC event entity."""
|
|
|
|
entity_description: ProtectEventEntityDescription
|
|
|
|
@callback
|
|
@override
|
|
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> 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.type is EventType.NFC_CARD_SCANNED
|
|
):
|
|
event_data = {
|
|
ATTR_EVENT_ID: event.id,
|
|
KEYRINGS_USER_FULL_NAME: "",
|
|
KEYRINGS_ULP_ID: "",
|
|
KEYRINGS_USER_STATUS: "",
|
|
KEYRINGS_KEY_TYPE_ID_NFC: "",
|
|
}
|
|
|
|
if event.metadata and event.metadata.nfc and event.metadata.nfc.nfc_id:
|
|
nfc_id = event.metadata.nfc.nfc_id
|
|
event_data[KEYRINGS_KEY_TYPE_ID_NFC] = nfc_id
|
|
keyring = self.data.api.bootstrap.keyrings.by_registry_id(nfc_id)
|
|
if keyring and keyring.ulp_user:
|
|
_add_ulp_user_infos(
|
|
self.data.api.bootstrap, event_data, keyring.ulp_user
|
|
)
|
|
|
|
self._trigger_event(EVENT_TYPE_NFC_SCANNED, event_data)
|
|
self.async_write_ha_state()
|
|
|
|
|
|
class ProtectDeviceFingerprintEventEntity(
|
|
EventEntityMixin, ProtectDeviceEntity, EventEntity
|
|
):
|
|
"""A UniFi Protect fingerprint event entity."""
|
|
|
|
entity_description: ProtectEventEntityDescription
|
|
|
|
@callback
|
|
@override
|
|
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> 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.type is EventType.FINGERPRINT_IDENTIFIED
|
|
):
|
|
event_data = {
|
|
ATTR_EVENT_ID: event.id,
|
|
KEYRINGS_USER_FULL_NAME: "",
|
|
KEYRINGS_ULP_ID: "",
|
|
}
|
|
event_identified = EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED
|
|
if (
|
|
event.metadata
|
|
and event.metadata.fingerprint
|
|
and event.metadata.fingerprint.ulp_id
|
|
):
|
|
event_identified = EVENT_TYPE_FINGERPRINT_IDENTIFIED
|
|
ulp_id = event.metadata.fingerprint.ulp_id
|
|
if ulp_id:
|
|
event_data[KEYRINGS_ULP_ID] = ulp_id
|
|
_add_ulp_user_infos(self.data.api.bootstrap, event_data, ulp_id)
|
|
|
|
self._trigger_event(event_identified, event_data)
|
|
self.async_write_ha_state()
|
|
|
|
|
|
class ProtectDeviceVehicleEventEntity(
|
|
EventEntityMixin, ProtectDeviceEntity, EventEntity
|
|
):
|
|
"""A UniFi Protect vehicle detection event entity.
|
|
|
|
Vehicle detection events use a delayed firing mechanism to allow time for
|
|
the best thumbnail (with license plate recognition data) to arrive. The
|
|
timer is extended each time new thumbnails arrive for the same event. If
|
|
a new event arrives while a timer is pending, the old event fires immediately
|
|
with its stored thumbnails, then a new timer starts for the new event.
|
|
"""
|
|
|
|
entity_description: ProtectEventEntityDescription
|
|
_thumbnail_timer_cancel: CALLBACK_TYPE | None = None
|
|
_latest_event_id: str | None = None
|
|
_latest_thumbnails: list[EventDetectedThumbnail] | None = None
|
|
_thumbnail_timer_due: float = 0.0 # Loop time when timer should fire
|
|
_fired_event_id: str | None = None # Track last fired event to prevent duplicates
|
|
_fired_event_data: dict[str, Any] | None = None # Track event data when fired
|
|
|
|
@override
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Register cleanup callback when entity is added."""
|
|
await super().async_added_to_hass()
|
|
self.async_on_remove(self._cancel_thumbnail_timer)
|
|
|
|
@callback
|
|
def _cancel_thumbnail_timer(self) -> None:
|
|
"""Cancel pending thumbnail timer if one exists."""
|
|
if self._thumbnail_timer_cancel:
|
|
self._thumbnail_timer_cancel()
|
|
self._thumbnail_timer_cancel = None
|
|
|
|
@callback
|
|
def _async_timer_callback(self, *_: Any) -> None:
|
|
"""Handle timer expiration - fire the vehicle event.
|
|
|
|
If the due time was extended (new thumbnails arrived), re-arm the timer.
|
|
Otherwise, fire the event with the stored thumbnails.
|
|
"""
|
|
self._thumbnail_timer_cancel = None
|
|
if self._thumbnail_timer_due > self.hass.loop.time():
|
|
# Timer fired early because due time was extended; re-arm
|
|
self._async_set_thumbnail_timer()
|
|
return
|
|
|
|
if self._latest_event_id:
|
|
self._fire_vehicle_event(self._latest_event_id, self._latest_thumbnails)
|
|
|
|
@staticmethod
|
|
def _get_vehicle_thumbnails(event: Event) -> list[EventDetectedThumbnail]:
|
|
"""Get vehicle thumbnails from event."""
|
|
if event.metadata and event.metadata.detected_thumbnails:
|
|
return [
|
|
t for t in event.metadata.detected_thumbnails if t.type == "vehicle"
|
|
]
|
|
return []
|
|
|
|
@staticmethod
|
|
def _build_event_data(
|
|
event_id: str, thumbnails: list[EventDetectedThumbnail]
|
|
) -> dict[str, Any]:
|
|
"""Build event data dictionary from thumbnails."""
|
|
event_data: dict[str, Any] = {
|
|
ATTR_EVENT_ID: event_id,
|
|
"thumbnail_count": len(thumbnails),
|
|
}
|
|
|
|
thumbnail = max(thumbnails, key=_thumbnail_sort_key)
|
|
|
|
# Add confidence if available
|
|
if thumbnail.confidence is not None:
|
|
event_data["confidence"] = thumbnail.confidence
|
|
|
|
# Add best detection frame timestamp
|
|
if thumbnail.clock_best_wall is not None:
|
|
event_data["clock_best_wall"] = thumbnail.clock_best_wall.isoformat()
|
|
|
|
# License plate from group.matched_name (UFP 6.0+) or name field (older)
|
|
if thumbnail.group and thumbnail.group.matched_name:
|
|
event_data["license_plate"] = thumbnail.group.matched_name
|
|
elif thumbnail.name:
|
|
event_data["license_plate"] = thumbnail.name
|
|
|
|
# Add all thumbnail attributes as dict
|
|
if thumbnail.attributes:
|
|
event_data["attributes"] = thumbnail.attributes.unifi_dict()
|
|
|
|
return event_data
|
|
|
|
@callback
|
|
def _fire_vehicle_event(
|
|
self, event_id: str, thumbnails: list[EventDetectedThumbnail] | None = None
|
|
) -> None:
|
|
"""Fire the vehicle detection event with best available thumbnail.
|
|
|
|
Args:
|
|
event_id: The event ID to include in the fired event data.
|
|
thumbnails: Pre-stored thumbnails to use. If None, fetches from
|
|
the current event (used when event is still active).
|
|
"""
|
|
if thumbnails is None:
|
|
# No stored thumbnails; try to get from current event
|
|
event = self.entity_description.get_event_obj(self.device)
|
|
if not event or event.id != event_id:
|
|
return
|
|
thumbnails = self._get_vehicle_thumbnails(event)
|
|
|
|
if not thumbnails:
|
|
return
|
|
|
|
event_data = self._build_event_data(event_id, thumbnails)
|
|
|
|
# Prevent duplicate firing of same event with same data
|
|
if self._fired_event_id == event_id and self._fired_event_data == event_data:
|
|
return
|
|
|
|
# Mark this event as fired with its data
|
|
self._fired_event_id = event_id
|
|
self._fired_event_data = event_data
|
|
|
|
self._trigger_event(EVENT_TYPE_VEHICLE_DETECTED, event_data)
|
|
self.async_write_ha_state()
|
|
|
|
@callback
|
|
def _async_set_thumbnail_timer(self) -> None:
|
|
"""Schedule the thumbnail timer to fire at _thumbnail_timer_due."""
|
|
self._thumbnail_timer_cancel = async_call_at(
|
|
self.hass,
|
|
self._async_timer_callback,
|
|
self._thumbnail_timer_due,
|
|
)
|
|
|
|
@callback
|
|
@override
|
|
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
|
|
description = self.entity_description
|
|
|
|
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
|
|
|
|
# Process vehicle detection events with thumbnails
|
|
if (
|
|
event
|
|
and event.type is EventType.SMART_DETECT
|
|
and (thumbnails := self._get_vehicle_thumbnails(event))
|
|
):
|
|
# Skip if same event with same data (no changes)
|
|
if (
|
|
self._fired_event_id == event.id
|
|
and self._fired_event_data
|
|
== self._build_event_data(event.id, thumbnails)
|
|
):
|
|
return
|
|
|
|
# New event arrived while timer pending for different event?
|
|
# Fire the old event immediately since it has completed
|
|
if self._latest_event_id and self._latest_event_id != event.id:
|
|
# Only fire if we haven't already (shouldn't happen, but defensive)
|
|
self._fire_vehicle_event(self._latest_event_id, self._latest_thumbnails)
|
|
self._cancel_thumbnail_timer()
|
|
|
|
# Store event data and extend/start the timer
|
|
# Timer extension allows better thumbnails (with LPR) to arrive
|
|
self._latest_event_id = event.id
|
|
self._latest_thumbnails = thumbnails
|
|
self._thumbnail_timer_due = (
|
|
self.hass.loop.time() + VEHICLE_EVENT_DELAY_SECONDS
|
|
)
|
|
# Only schedule if no timer running; existing timer will re-arm
|
|
if self._thumbnail_timer_cancel is None:
|
|
self._async_set_thumbnail_timer()
|
|
|
|
|
|
class ProtectDeviceSmartDetectEventEntity(ProtectDevicePublicEventEntity):
|
|
"""A UniFi Protect smart-detect event entity driven by the public events WS.
|
|
|
|
Used for object types that Protect models as discrete, point-in-time
|
|
detections (e.g. package): the camera fires once with a cooldown and the
|
|
smart-detect event is recorded already-ended, so a sustained binary sensor
|
|
can never reflect it. The public events websocket delivers these as proper
|
|
``smartDetectZone`` events (the private API only exposes the unhandled
|
|
``smartDetectObject`` model), so we subscribe there and fire a momentary
|
|
event when the description's object type matches.
|
|
"""
|
|
|
|
entity_description: ProtectEventEntityDescription
|
|
|
|
@override
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Subscribe to public smart-detect events for this camera."""
|
|
await super().async_added_to_hass()
|
|
for event_type in _SMART_DETECT_EVENT_TYPES:
|
|
self.async_on_remove(
|
|
self.data.async_subscribe_public_event(
|
|
self.device.id, event_type, self._async_smart_detect_event
|
|
)
|
|
)
|
|
|
|
@callback
|
|
def _async_smart_detect_event(self, event: ProtectEvent) -> None:
|
|
description = self.entity_description
|
|
event_types = description.event_types
|
|
if event_types and description.ufp_obj_type in event.smart_detect_types:
|
|
self._fire_once(event, event_types[0], {ATTR_EVENT_ID: event.id})
|
|
|
|
|
|
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
|
|
# Friendly event-type slugs where the raw enum value is unclear or prefixed;
|
|
# unlisted types fall back to a snake_case slug so new ones still auto-surface.
|
|
_EVENT_TYPE_OVERRIDES = {
|
|
SmartDetectObjectType.SMOKE: "smoke",
|
|
SmartDetectObjectType.CMONX: "co",
|
|
SmartDetectObjectType.SIREN: "siren",
|
|
SmartDetectObjectType.BABY_CRY: "baby_cry",
|
|
SmartDetectObjectType.SPEAK: "speaking",
|
|
SmartDetectObjectType.BARK: "bark",
|
|
SmartDetectObjectType.BURGLAR: "car_alarm",
|
|
SmartDetectObjectType.CAR_HORN: "car_horn",
|
|
SmartDetectObjectType.GLASS_BREAK: "glass_break",
|
|
}
|
|
|
|
|
|
def _event_type(detected: SmartDetectObjectType) -> str:
|
|
"""Stable snake_case event type for a detection (HA translation-key rules)."""
|
|
return (
|
|
_EVENT_TYPE_OVERRIDES.get(detected)
|
|
or _CAMEL_BOUNDARY.sub("_", detected.value).lower()
|
|
)
|
|
|
|
|
|
_SMART_OBJECT_EVENT_TYPES = [
|
|
_event_type(t) for t in SmartDetectObjectType if t.audio_type is None
|
|
]
|
|
_SMART_AUDIO_EVENT_TYPES = [
|
|
_event_type(t) for t in SmartDetectObjectType if t.audio_type is not None
|
|
]
|
|
|
|
|
|
class ProtectDeviceDetectionEventEntity(ProtectDevicePublicEventEntity):
|
|
"""A camera smart-detect category event entity (object or audio), public WS.
|
|
|
|
Fires a momentary event for each detected type the entity surfaces. The
|
|
``event_types`` are derived from the uiprotect enum, so a new detection type
|
|
is surfaced automatically without code changes (only a state label is added).
|
|
The subscribed category comes from ``ufp_public_event_types``; the motion
|
|
variant overrides the firing.
|
|
"""
|
|
|
|
entity_description: ProtectDetectionEventEntityDescription
|
|
|
|
@override
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Subscribe to the category's public detection events."""
|
|
await super().async_added_to_hass()
|
|
for event_type in self.entity_description.ufp_public_event_types:
|
|
self.async_on_remove(
|
|
self.data.async_subscribe_public_event(
|
|
self.device.id,
|
|
event_type,
|
|
self._async_detection_event,
|
|
)
|
|
)
|
|
|
|
@callback
|
|
def _async_detection_event(self, event: ProtectEvent) -> None:
|
|
allowed = self.entity_description.event_types or ()
|
|
# One fire per detected type so each stays independently automatable
|
|
# (incl. types with no binary sensor); carries the co-detected set known
|
|
# at fire time (types can still arrive on a later update).
|
|
detected = [_event_type(t) for t in event.smart_detect_types]
|
|
for event_type in detected:
|
|
if event_type in allowed:
|
|
self._fire_once(
|
|
event,
|
|
event_type,
|
|
{ATTR_EVENT_ID: event.id, ATTR_SMART_DETECT_TYPES: detected},
|
|
)
|
|
|
|
|
|
class ProtectDeviceMotionEventEntity(ProtectDeviceDetectionEventEntity):
|
|
"""A camera motion-detection event entity (public events WS)."""
|
|
|
|
@callback
|
|
@override
|
|
def _async_detection_event(self, event: ProtectEvent) -> None:
|
|
self._fire_once(event, EventType.MOTION.value, {ATTR_EVENT_ID: event.id})
|
|
|
|
|
|
EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
|
|
ProtectEventEntityDescription(
|
|
key="doorbell",
|
|
translation_key="doorbell",
|
|
device_class=EventDeviceClass.DOORBELL,
|
|
ufp_required_field="feature_flags.is_doorbell",
|
|
event_types=[DoorbellEventType.RING],
|
|
entity_class=ProtectDeviceRingEventEntity,
|
|
),
|
|
ProtectEventEntityDescription(
|
|
key="nfc",
|
|
translation_key="nfc",
|
|
ufp_required_field="feature_flags.support_nfc",
|
|
ufp_event_obj="last_nfc_card_scanned_event",
|
|
event_types=[EVENT_TYPE_NFC_SCANNED],
|
|
entity_class=ProtectDeviceNFCEventEntity,
|
|
),
|
|
ProtectEventEntityDescription(
|
|
key="fingerprint",
|
|
translation_key="fingerprint",
|
|
ufp_required_field="feature_flags.has_fingerprint_sensor",
|
|
ufp_event_obj="last_fingerprint_identified_event",
|
|
event_types=[
|
|
EVENT_TYPE_FINGERPRINT_IDENTIFIED,
|
|
EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
|
|
],
|
|
entity_class=ProtectDeviceFingerprintEventEntity,
|
|
),
|
|
ProtectEventEntityDescription(
|
|
key="vehicle",
|
|
translation_key="vehicle",
|
|
ufp_required_field="feature_flags.has_smart_detect",
|
|
ufp_event_obj="last_smart_detect_event",
|
|
event_types=[EVENT_TYPE_VEHICLE_DETECTED],
|
|
entity_class=ProtectDeviceVehicleEventEntity,
|
|
),
|
|
ProtectEventEntityDescription(
|
|
key="package",
|
|
translation_key="package",
|
|
ufp_required_field="can_detect_package",
|
|
ufp_obj_type=SmartDetectObjectType.PACKAGE,
|
|
event_types=[EVENT_TYPE_PACKAGE_DETECTED],
|
|
entity_class=ProtectDeviceSmartDetectEventEntity,
|
|
),
|
|
ProtectDetectionEventEntityDescription(
|
|
key="motion_detection",
|
|
translation_key="motion_detection",
|
|
device_class=EventDeviceClass.MOTION,
|
|
event_types=[EventType.MOTION.value],
|
|
ufp_public_event_types=(EventType.MOTION,),
|
|
entity_class=ProtectDeviceMotionEventEntity,
|
|
),
|
|
ProtectDetectionEventEntityDescription(
|
|
key="smart_detection",
|
|
translation_key="smart_detection",
|
|
ufp_required_field="feature_flags.has_smart_detect",
|
|
event_types=_SMART_OBJECT_EVENT_TYPES,
|
|
ufp_public_event_types=_SMART_DETECT_EVENT_TYPES,
|
|
entity_class=ProtectDeviceDetectionEventEntity,
|
|
),
|
|
ProtectDetectionEventEntityDescription(
|
|
key="sound_detection",
|
|
translation_key="sound_detection",
|
|
ufp_required_field="feature_flags.smart_detect_audio_types",
|
|
event_types=_SMART_AUDIO_EVENT_TYPES,
|
|
ufp_public_event_types=(EventType.SMART_AUDIO_DETECT,),
|
|
entity_class=ProtectDeviceDetectionEventEntity,
|
|
),
|
|
)
|
|
|
|
|
|
@callback
|
|
def _async_event_entities(
|
|
data: ProtectData,
|
|
ufp_device: ProtectAdoptableDeviceModel | None = None,
|
|
) -> list[ProtectDeviceEntity]:
|
|
return [
|
|
description.entity_class(data, device, description)
|
|
for device in (data.get_cameras() if ufp_device is None else [ufp_device])
|
|
for description in EVENT_DESCRIPTIONS
|
|
if description.has_required(device)
|
|
]
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: UFPConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up event entities for UniFi Protect integration."""
|
|
data = entry.runtime_data
|
|
|
|
@callback
|
|
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
|
|
# AiPort inherits from Camera but should not create camera-specific entities
|
|
if device.is_adopted and device.model is ModelType.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))
|