|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import dataclasses |
| 6 | +from typing import Any |
| 7 | + |
| 8 | +from uiprotect.data.nvr import Event, EventDetectedThumbnail |
6 | 9 |
|
7 | 10 | from homeassistant.components.event import ( |
8 | 11 | EventDeviceClass, |
9 | 12 | EventEntity, |
10 | 13 | EventEntityDescription, |
11 | 14 | ) |
12 | | -from homeassistant.core import HomeAssistant, callback |
| 15 | +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback |
13 | 16 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback |
| 17 | +from homeassistant.helpers.event import async_call_at |
14 | 18 |
|
15 | 19 | from . import Bootstrap |
16 | 20 | from .const import ( |
|
19 | 23 | EVENT_TYPE_FINGERPRINT_IDENTIFIED, |
20 | 24 | EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED, |
21 | 25 | EVENT_TYPE_NFC_SCANNED, |
| 26 | + EVENT_TYPE_VEHICLE_DETECTED, |
22 | 27 | KEYRINGS_KEY_TYPE_ID_NFC, |
23 | 28 | KEYRINGS_ULP_ID, |
24 | 29 | KEYRINGS_USER_FULL_NAME, |
25 | 30 | KEYRINGS_USER_STATUS, |
| 31 | + VEHICLE_EVENT_DELAY_SECONDS, |
26 | 32 | ) |
27 | 33 | from .data import ( |
28 | 34 | Camera, |
|
35 | 41 | from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin |
36 | 42 |
|
37 | 43 |
|
| 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 | + |
38 | 55 | def _add_ulp_user_infos( |
39 | 56 | bootstrap: Bootstrap, event_data: dict[str, str], ulp_id: str |
40 | 57 | ) -> None: |
@@ -167,6 +184,152 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: |
167 | 184 | self.async_write_ha_state() |
168 | 185 |
|
169 | 186 |
|
| 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 | + |
170 | 333 | EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( |
171 | 334 | ProtectEventEntityDescription( |
172 | 335 | key="doorbell", |
@@ -199,6 +362,15 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: |
199 | 362 | ], |
200 | 363 | entity_class=ProtectDeviceFingerprintEventEntity, |
201 | 364 | ), |
| 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 | + ), |
202 | 374 | ) |
203 | 375 |
|
204 | 376 |
|
|
0 commit comments