Skip to content

Commit d2ba7e8

Browse files
RaHehlbdraco
andauthored
UnifiProtect add vehicle detection event entity with license plate recognition support (home-assistant#157203)
Co-authored-by: J. Nick Koston <[email protected]>
1 parent 405c2f9 commit d2ba7e8

File tree

4 files changed

+906
-13
lines changed

4 files changed

+906
-13
lines changed

homeassistant/components/unifiprotect/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@
8383
EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified"
8484
EVENT_TYPE_NFC_SCANNED: Final = "scanned"
8585
EVENT_TYPE_DOORBELL_RING: Final = "ring"
86+
EVENT_TYPE_VEHICLE_DETECTED: Final = "detected"
87+
88+
# Delay in seconds before firing vehicle event after last thumbnail
89+
VEHICLE_EVENT_DELAY_SECONDS: Final = 3
8690

8791
KEYRINGS_ULP_ID: Final = "ulp_id"
8892
KEYRINGS_USER_STATUS: Final = "user_status"

homeassistant/components/unifiprotect/event.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
from __future__ import annotations
44

55
import dataclasses
6+
from typing import Any
7+
8+
from uiprotect.data.nvr import Event, EventDetectedThumbnail
69

710
from homeassistant.components.event import (
811
EventDeviceClass,
912
EventEntity,
1013
EventEntityDescription,
1114
)
12-
from homeassistant.core import HomeAssistant, callback
15+
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
1316
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
17+
from homeassistant.helpers.event import async_call_at
1418

1519
from . import Bootstrap
1620
from .const import (
@@ -19,10 +23,12 @@
1923
EVENT_TYPE_FINGERPRINT_IDENTIFIED,
2024
EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
2125
EVENT_TYPE_NFC_SCANNED,
26+
EVENT_TYPE_VEHICLE_DETECTED,
2227
KEYRINGS_KEY_TYPE_ID_NFC,
2328
KEYRINGS_ULP_ID,
2429
KEYRINGS_USER_FULL_NAME,
2530
KEYRINGS_USER_STATUS,
31+
VEHICLE_EVENT_DELAY_SECONDS,
2632
)
2733
from .data import (
2834
Camera,
@@ -35,6 +41,17 @@
3541
from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin
3642

3743

44+
# Select best thumbnail
45+
# Prefer thumbnails with LPR data, sorted by confidence
46+
# LPR can be in: 1) group.matched_name (UFP 6.0+) or 2) name field
47+
def _thumbnail_sort_key(t: EventDetectedThumbnail) -> tuple[bool, float, float]:
48+
"""Sort key: (has_lpr, confidence, clock_best_wall)."""
49+
has_lpr = bool((t.group and t.group.matched_name) or (t.name and len(t.name) > 0))
50+
confidence = t.confidence if t.confidence else 0.0
51+
clock = t.clock_best_wall.timestamp() if t.clock_best_wall else 0.0
52+
return (has_lpr, confidence, clock)
53+
54+
3855
def _add_ulp_user_infos(
3956
bootstrap: Bootstrap, event_data: dict[str, str], ulp_id: str
4057
) -> None:
@@ -167,6 +184,152 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
167184
self.async_write_ha_state()
168185

169186

187+
class ProtectDeviceVehicleEventEntity(
188+
EventEntityMixin, ProtectDeviceEntity, EventEntity
189+
):
190+
"""A UniFi Protect vehicle detection event entity.
191+
192+
Vehicle detection events use a delayed firing mechanism to allow time for
193+
the best thumbnail (with license plate recognition data) to arrive. The
194+
timer is extended each time new thumbnails arrive for the same event. If
195+
a new event arrives while a timer is pending, the old event fires immediately
196+
with its stored thumbnails, then a new timer starts for the new event.
197+
"""
198+
199+
entity_description: ProtectEventEntityDescription
200+
_thumbnail_timer_cancel: CALLBACK_TYPE | None = None
201+
_latest_event_id: str | None = None
202+
_latest_thumbnails: list[EventDetectedThumbnail] | None = None
203+
_thumbnail_timer_due: float = 0.0 # Loop time when timer should fire
204+
205+
async def async_added_to_hass(self) -> None:
206+
"""Register cleanup callback when entity is added."""
207+
await super().async_added_to_hass()
208+
self.async_on_remove(self._cancel_thumbnail_timer)
209+
210+
@callback
211+
def _cancel_thumbnail_timer(self) -> None:
212+
"""Cancel pending thumbnail timer if one exists."""
213+
if self._thumbnail_timer_cancel:
214+
self._thumbnail_timer_cancel()
215+
self._thumbnail_timer_cancel = None
216+
217+
@callback
218+
def _async_timer_callback(self, *_: Any) -> None:
219+
"""Handle timer expiration - fire the vehicle event.
220+
221+
If the due time was extended (new thumbnails arrived), re-arm the timer.
222+
Otherwise, fire the event with the stored thumbnails.
223+
"""
224+
self._thumbnail_timer_cancel = None
225+
if self._thumbnail_timer_due > self.hass.loop.time():
226+
# Timer fired early because due time was extended; re-arm
227+
self._async_set_thumbnail_timer()
228+
return
229+
230+
if self._latest_event_id:
231+
self._fire_vehicle_event(self._latest_event_id, self._latest_thumbnails)
232+
233+
@staticmethod
234+
def _get_vehicle_thumbnails(event: Event) -> list[EventDetectedThumbnail]:
235+
"""Get vehicle thumbnails from event."""
236+
if event.metadata and event.metadata.detected_thumbnails:
237+
return [
238+
t for t in event.metadata.detected_thumbnails if t.type == "vehicle"
239+
]
240+
return []
241+
242+
@callback
243+
def _fire_vehicle_event(
244+
self, event_id: str, thumbnails: list[EventDetectedThumbnail] | None = None
245+
) -> None:
246+
"""Fire the vehicle detection event with best available thumbnail.
247+
248+
Args:
249+
event_id: The event ID to include in the fired event data.
250+
thumbnails: Pre-stored thumbnails to use. If None, fetches from
251+
the current event (used when event is still active).
252+
"""
253+
if thumbnails is None:
254+
# No stored thumbnails; try to get from current event
255+
event = self.entity_description.get_event_obj(self.device)
256+
if not event or event.id != event_id:
257+
return
258+
thumbnails = self._get_vehicle_thumbnails(event)
259+
260+
if not thumbnails:
261+
return
262+
263+
# Start with just the event ID
264+
event_data: dict[str, Any] = {
265+
ATTR_EVENT_ID: event_id,
266+
"thumbnail_count": len(thumbnails),
267+
}
268+
269+
thumbnail = max(thumbnails, key=_thumbnail_sort_key)
270+
271+
# Add confidence if available
272+
if thumbnail.confidence is not None:
273+
event_data["confidence"] = thumbnail.confidence
274+
275+
# Add best detection frame timestamp
276+
if thumbnail.clock_best_wall is not None:
277+
event_data["clock_best_wall"] = thumbnail.clock_best_wall.isoformat()
278+
279+
# License plate from group.matched_name (UFP 6.0+) or name field (older)
280+
if thumbnail.group and thumbnail.group.matched_name:
281+
event_data["license_plate"] = thumbnail.group.matched_name
282+
elif thumbnail.name:
283+
event_data["license_plate"] = thumbnail.name
284+
285+
# Add all thumbnail attributes as dict
286+
if thumbnail.attributes:
287+
event_data["attributes"] = thumbnail.attributes.unifi_dict()
288+
289+
self._trigger_event(EVENT_TYPE_VEHICLE_DETECTED, event_data)
290+
self.async_write_ha_state()
291+
292+
@callback
293+
def _async_set_thumbnail_timer(self) -> None:
294+
"""Schedule the thumbnail timer to fire at _thumbnail_timer_due."""
295+
self._thumbnail_timer_cancel = async_call_at(
296+
self.hass,
297+
self._async_timer_callback,
298+
self._thumbnail_timer_due,
299+
)
300+
301+
@callback
302+
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
303+
description = self.entity_description
304+
305+
super()._async_update_device_from_protect(device)
306+
if event := description.get_event_obj(device):
307+
self._event = event
308+
self._event_end = event.end if event else None
309+
310+
# Process vehicle detection events with thumbnails
311+
if (
312+
event
313+
and event.type is EventType.SMART_DETECT
314+
and (thumbnails := self._get_vehicle_thumbnails(event))
315+
):
316+
# New event arrived while timer pending for different event?
317+
# Fire the old event immediately since it has completed
318+
if self._latest_event_id and self._latest_event_id != event.id:
319+
self._fire_vehicle_event(self._latest_event_id, self._latest_thumbnails)
320+
321+
# Store event data and extend/start the timer
322+
# Timer extension allows better thumbnails (with LPR) to arrive
323+
self._latest_event_id = event.id
324+
self._latest_thumbnails = thumbnails
325+
self._thumbnail_timer_due = (
326+
self.hass.loop.time() + VEHICLE_EVENT_DELAY_SECONDS
327+
)
328+
# Only schedule if no timer running; existing timer will re-arm
329+
if self._thumbnail_timer_cancel is None:
330+
self._async_set_thumbnail_timer()
331+
332+
170333
EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
171334
ProtectEventEntityDescription(
172335
key="doorbell",
@@ -199,6 +362,15 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
199362
],
200363
entity_class=ProtectDeviceFingerprintEventEntity,
201364
),
365+
ProtectEventEntityDescription(
366+
key="vehicle",
367+
translation_key="vehicle",
368+
icon="mdi:car",
369+
ufp_required_field="feature_flags.has_smart_detect",
370+
ufp_event_obj="last_smart_detect_event",
371+
event_types=[EVENT_TYPE_VEHICLE_DETECTED],
372+
entity_class=ProtectDeviceVehicleEventEntity,
373+
),
202374
)
203375

204376

homeassistant/components/unifiprotect/strings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,16 @@
253253
}
254254
}
255255
}
256+
},
257+
"vehicle": {
258+
"name": "Vehicle",
259+
"state_attributes": {
260+
"event_type": {
261+
"state": {
262+
"detected": "Detected"
263+
}
264+
}
265+
}
256266
}
257267
},
258268
"media_player": {

0 commit comments

Comments
 (0)