Skip to content

Commit 839f647

Browse files
RaHehlbdraco
andauthored
Unifiprotect Prevent duplicate vehicle detection events from firing (home-assistant#157278)
Co-authored-by: J. Nick Koston <[email protected]>
1 parent 7c2741b commit 839f647

File tree

2 files changed

+245
-22
lines changed

2 files changed

+245
-22
lines changed

homeassistant/components/unifiprotect/event.py

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ class ProtectDeviceVehicleEventEntity(
201201
_latest_event_id: str | None = None
202202
_latest_thumbnails: list[EventDetectedThumbnail] | None = None
203203
_thumbnail_timer_due: float = 0.0 # Loop time when timer should fire
204+
_fired_event_id: str | None = None # Track last fired event to prevent duplicates
205+
_fired_event_data: dict[str, Any] | None = None # Track event data when fired
204206

205207
async def async_added_to_hass(self) -> None:
206208
"""Register cleanup callback when entity is added."""
@@ -239,28 +241,11 @@ def _get_vehicle_thumbnails(event: Event) -> list[EventDetectedThumbnail]:
239241
]
240242
return []
241243

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
244+
@staticmethod
245+
def _build_event_data(
246+
event_id: str, thumbnails: list[EventDetectedThumbnail]
247+
) -> dict[str, Any]:
248+
"""Build event data dictionary from thumbnails."""
264249
event_data: dict[str, Any] = {
265250
ATTR_EVENT_ID: event_id,
266251
"thumbnail_count": len(thumbnails),
@@ -286,6 +271,39 @@ def _fire_vehicle_event(
286271
if thumbnail.attributes:
287272
event_data["attributes"] = thumbnail.attributes.unifi_dict()
288273

274+
return event_data
275+
276+
@callback
277+
def _fire_vehicle_event(
278+
self, event_id: str, thumbnails: list[EventDetectedThumbnail] | None = None
279+
) -> None:
280+
"""Fire the vehicle detection event with best available thumbnail.
281+
282+
Args:
283+
event_id: The event ID to include in the fired event data.
284+
thumbnails: Pre-stored thumbnails to use. If None, fetches from
285+
the current event (used when event is still active).
286+
"""
287+
if thumbnails is None:
288+
# No stored thumbnails; try to get from current event
289+
event = self.entity_description.get_event_obj(self.device)
290+
if not event or event.id != event_id:
291+
return
292+
thumbnails = self._get_vehicle_thumbnails(event)
293+
294+
if not thumbnails:
295+
return
296+
297+
event_data = self._build_event_data(event_id, thumbnails)
298+
299+
# Prevent duplicate firing of same event with same data
300+
if self._fired_event_id == event_id and self._fired_event_data == event_data:
301+
return
302+
303+
# Mark this event as fired with its data
304+
self._fired_event_id = event_id
305+
self._fired_event_data = event_data
306+
289307
self._trigger_event(EVENT_TYPE_VEHICLE_DETECTED, event_data)
290308
self.async_write_ha_state()
291309

@@ -313,10 +331,20 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
313331
and event.type is EventType.SMART_DETECT
314332
and (thumbnails := self._get_vehicle_thumbnails(event))
315333
):
334+
# Skip if same event with same data (no changes)
335+
if (
336+
self._fired_event_id == event.id
337+
and self._fired_event_data
338+
== self._build_event_data(event.id, thumbnails)
339+
):
340+
return
341+
316342
# New event arrived while timer pending for different event?
317343
# Fire the old event immediately since it has completed
318344
if self._latest_event_id and self._latest_event_id != event.id:
345+
# Only fire if we haven't already (shouldn't happen, but defensive)
319346
self._fire_vehicle_event(self._latest_event_id, self._latest_thumbnails)
347+
self._cancel_thumbnail_timer()
320348

321349
# Store event data and extend/start the timer
322350
# Timer extension allows better thumbnails (with LPR) to arrive

tests/components/unifiprotect/test_event.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,3 +1395,198 @@ async def test_vehicle_detection_timer_cleanup_on_remove(
13951395
# Entity should be gone and no event should have fired
13961396
state = hass.states.get(entity_id)
13971397
assert state is None
1398+
1399+
1400+
async def test_vehicle_detection_refire_on_lpr_data(
1401+
hass: HomeAssistant,
1402+
ufp: MockUFPFixture,
1403+
doorbell: Camera,
1404+
unadopted_camera: Camera,
1405+
fixed_now: datetime,
1406+
) -> None:
1407+
"""Test that event refires when LPR data arrives after initial detection."""
1408+
1409+
await init_entry(hass, ufp, [doorbell, unadopted_camera])
1410+
assert_entity_counts(hass, Platform.EVENT, 4, 4)
1411+
events: list[HAEvent] = []
1412+
1413+
@callback
1414+
def _capture_event(event: HAEvent) -> None:
1415+
events.append(event)
1416+
1417+
_, entity_id = await ids_from_device_description(
1418+
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
1419+
)
1420+
1421+
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
1422+
1423+
# Create event with vehicle thumbnail but NO LPR data
1424+
event = Event(
1425+
model=ModelType.EVENT,
1426+
id="test_refire_lpr_id",
1427+
type=EventType.SMART_DETECT,
1428+
start=fixed_now - timedelta(seconds=1),
1429+
end=None,
1430+
score=100,
1431+
smart_detect_types=[],
1432+
smart_detect_event_ids=[],
1433+
camera_id=doorbell.id,
1434+
api=ufp.api,
1435+
metadata={
1436+
"detected_thumbnails": [
1437+
{
1438+
"type": "vehicle",
1439+
"confidence": 85,
1440+
"clock_best_wall": fixed_now,
1441+
"cropped_id": "test_thumb_id",
1442+
}
1443+
]
1444+
},
1445+
)
1446+
1447+
new_camera = doorbell.model_copy()
1448+
new_camera.last_smart_detect_event_id = "test_refire_lpr_id"
1449+
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
1450+
ufp.api.bootstrap.events = {event.id: event}
1451+
1452+
mock_msg = Mock()
1453+
mock_msg.changed_data = {}
1454+
mock_msg.new_obj = event
1455+
ufp.ws_msg(mock_msg)
1456+
1457+
# Wait for the timer to expire - first event should fire without LPR
1458+
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
1459+
await hass.async_block_till_done()
1460+
1461+
# Should have received first event without LPR
1462+
assert len(events) == 1
1463+
state = events[0].data["new_state"]
1464+
assert state
1465+
assert state.attributes[ATTR_EVENT_ID] == "test_refire_lpr_id"
1466+
assert state.attributes["confidence"] == 85
1467+
assert "license_plate" not in state.attributes
1468+
1469+
# Now LPR data arrives for the same event
1470+
event.metadata = {
1471+
"detected_thumbnails": [
1472+
{
1473+
"type": "vehicle",
1474+
"confidence": 85,
1475+
"clock_best_wall": fixed_now,
1476+
"cropped_id": "test_thumb_id",
1477+
},
1478+
{
1479+
"type": "vehicle",
1480+
"confidence": 95,
1481+
"clock_best_wall": fixed_now + timedelta(seconds=1),
1482+
"cropped_id": "test_thumb_id_lpr",
1483+
"group": {
1484+
"id": "lpr_group",
1485+
"matched_name": "ABC123",
1486+
"confidence": 95,
1487+
},
1488+
},
1489+
]
1490+
}
1491+
1492+
ufp.api.bootstrap.events = {event.id: event}
1493+
mock_msg.new_obj = event
1494+
ufp.ws_msg(mock_msg)
1495+
1496+
# Wait for the new timer to expire
1497+
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
1498+
await hass.async_block_till_done()
1499+
1500+
# Should have received second event WITH LPR data
1501+
assert len(events) == 2
1502+
state = events[1].data["new_state"]
1503+
assert state
1504+
assert state.attributes[ATTR_EVENT_ID] == "test_refire_lpr_id"
1505+
assert state.attributes["confidence"] == 95
1506+
assert state.attributes["license_plate"] == "ABC123"
1507+
1508+
unsub()
1509+
1510+
1511+
async def test_vehicle_detection_no_refire_same_data(
1512+
hass: HomeAssistant,
1513+
ufp: MockUFPFixture,
1514+
doorbell: Camera,
1515+
unadopted_camera: Camera,
1516+
fixed_now: datetime,
1517+
) -> None:
1518+
"""Test that event does NOT refire when same data arrives again."""
1519+
1520+
await init_entry(hass, ufp, [doorbell, unadopted_camera])
1521+
assert_entity_counts(hass, Platform.EVENT, 4, 4)
1522+
events: list[HAEvent] = []
1523+
1524+
@callback
1525+
def _capture_event(event: HAEvent) -> None:
1526+
events.append(event)
1527+
1528+
_, entity_id = await ids_from_device_description(
1529+
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
1530+
)
1531+
1532+
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
1533+
1534+
# Create event with vehicle thumbnail
1535+
event = Event(
1536+
model=ModelType.EVENT,
1537+
id="test_no_refire_id",
1538+
type=EventType.SMART_DETECT,
1539+
start=fixed_now - timedelta(seconds=1),
1540+
end=None,
1541+
score=100,
1542+
smart_detect_types=[],
1543+
smart_detect_event_ids=[],
1544+
camera_id=doorbell.id,
1545+
api=ufp.api,
1546+
metadata={
1547+
"detected_thumbnails": [
1548+
{
1549+
"type": "vehicle",
1550+
"confidence": 90,
1551+
"clock_best_wall": fixed_now,
1552+
"cropped_id": "test_thumb_id",
1553+
"group": {
1554+
"id": "lpr_group",
1555+
"matched_name": "XYZ789",
1556+
"confidence": 90,
1557+
},
1558+
}
1559+
]
1560+
},
1561+
)
1562+
1563+
new_camera = doorbell.model_copy()
1564+
new_camera.last_smart_detect_event_id = "test_no_refire_id"
1565+
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
1566+
ufp.api.bootstrap.events = {event.id: event}
1567+
1568+
mock_msg = Mock()
1569+
mock_msg.changed_data = {}
1570+
mock_msg.new_obj = event
1571+
ufp.ws_msg(mock_msg)
1572+
1573+
# Wait for the timer to expire
1574+
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
1575+
await hass.async_block_till_done()
1576+
1577+
# Should have received one event
1578+
assert len(events) == 1
1579+
state = events[0].data["new_state"]
1580+
assert state
1581+
assert state.attributes[ATTR_EVENT_ID] == "test_no_refire_id"
1582+
assert state.attributes["license_plate"] == "XYZ789"
1583+
1584+
# Send the same event again with identical data
1585+
ufp.ws_msg(mock_msg)
1586+
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
1587+
await hass.async_block_till_done()
1588+
1589+
# Should NOT have received another event (same data)
1590+
assert len(events) == 1
1591+
1592+
unsub()

0 commit comments

Comments
 (0)