Files
core/tests/components/unifiprotect/test_event.py
T
2026-07-29 16:41:07 +02:00

2269 lines
69 KiB
Python

"""Test the UniFi Protect event platform."""
import asyncio
from datetime import datetime, timedelta
import json
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from uiprotect import EventChange, ProtectEvent, ProtectEventChannel
from uiprotect.data import (
AiPort,
Camera,
Event,
EventType,
ModelType,
SmartDetectAudioType,
SmartDetectObjectType,
)
from uiprotect.websocket import WebsocketState
from homeassistant.components.unifiprotect.const import (
ATTR_EVENT_ID,
ATTR_SMART_DETECT_TYPES,
DEFAULT_ATTRIBUTION,
EVENT_TYPE_PACKAGE_DETECTED,
)
from homeassistant.components.unifiprotect.event import (
_MAX_TRACKED_EVENTS,
EVENT_DESCRIPTIONS,
)
from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, Platform
from homeassistant.core import Event as HAEvent, HomeAssistant, callback
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.helpers.event import async_track_state_change_event
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
ids_from_device_description,
init_entry,
remove_entities,
setup_public_camera,
)
# Short delay for testing
TEST_VEHICLE_EVENT_DELAY = 0.05
@pytest.fixture(autouse=True)
def short_vehicle_delay():
"""Use a short delay for vehicle event tests."""
with patch(
"homeassistant.components.unifiprotect.event.VEHICLE_EVENT_DELAY_SECONDS",
TEST_VEHICLE_EVENT_DELAY,
):
yield
async def test_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera
) -> None:
"""Test removing and re-adding a camera device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
await remove_entities(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 0, 0)
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
async def test_doorbell_ring(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell ring event fired from the public events websocket."""
# Ring is delivered over the public events websocket, which is only
# subscribed once update_public() has primed the public bootstrap; the
# entity's availability also requires the public camera to resolve.
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
ufp.events_msg(
ProtectEvent(
id="test_ring_event",
type=EventType.RING,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_ring_event"
# A non-ring event on the same camera must not fire the doorbell entity.
ufp.events_msg(
ProtectEvent(
id="test_motion_event",
type=EventType.MOTION,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now,
end=None,
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 1
# Updates are dispatched too, but the entity fires each event id only
# once, so a repeat dispatch of the same ring event must be suppressed.
ufp.events_msg(
ProtectEvent(
id="test_ring_event",
type=EventType.RING,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
),
EventChange.UPDATED,
)
await hass.async_block_till_done()
assert len(events) == 1
unsub()
@pytest.mark.parametrize(
"event_type",
[
pytest.param(EventType.SMART_DETECT, id="zone"),
pytest.param(EventType.SMART_DETECT_LINE, id="line"),
pytest.param(EventType.SMART_DETECT_LOITER, id="loiter"),
],
)
async def test_package_detected(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
event_type: EventType,
) -> None:
"""Test a package detection event fired from the public events websocket."""
# Package detection is delivered over the public events websocket, which is
# only subscribed once update_public() has primed the public bootstrap; the
# entity's availability also requires the public camera to resolve.
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[4]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Package detection arrives on the public events websocket as a
# smartDetectZone, smartDetectLine, or smartDetectLoiterZone detection
# event with the package object type. Protect records it already-ended;
# the event entity fires on the detection start.
ufp.events_msg(
ProtectEvent(
id="test_package_event",
type=event_type,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.PACKAGE,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_package_event"
assert state.attributes["event_type"] == EVENT_TYPE_PACKAGE_DETECTED
# A non-package detection must not fire the package entity.
ufp.events_msg(
ProtectEvent(
id="test_person_event",
type=EventType.SMART_DETECT,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now,
end=None,
smart_detect_types=(SmartDetectObjectType.PERSON,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 1
# Updates are dispatched too, but the entity fires each (event id, type)
# once, so a repeat dispatch of the same package event must be suppressed.
ufp.events_msg(
ProtectEvent(
id="test_package_event",
type=EventType.SMART_DETECT,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.PACKAGE,),
),
EventChange.UPDATED,
)
await hass.async_block_till_done()
assert len(events) == 1
# Subscriptions are keyed by device_id alone: an event still dispatches when
# it carries no device_mac and the device is absent from the private
# bootstrap, proving the public event path does not depend on it.
ufp.api.bootstrap.id_lookup.pop(doorbell.id, None)
ufp.events_msg(
ProtectEvent(
id="test_package_event_no_private_device",
type=EventType.SMART_DETECT,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=None,
start=fixed_now,
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.PACKAGE,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
assert events[1].data["new_state"].attributes[ATTR_EVENT_ID] == (
"test_package_event_no_private_device"
)
# An event for a device without a matching subscription is dropped.
ufp.events_msg(
ProtectEvent(
id="test_package_event_unknown",
type=EventType.SMART_DETECT,
channel=ProtectEventChannel.DETECTION,
device_id="does-not-exist",
device_mac=None,
start=fixed_now,
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.PACKAGE,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
# A non-smart-detect event that happens to carry a matching object type is
# routed by type, so it must not reach the smart-detect entity.
ufp.events_msg(
ProtectEvent(
id="test_ring_with_package_types",
type=EventType.RING,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now,
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.PACKAGE,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
unsub()
async def test_doorbell_nfc_scanned(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell NFC scanned event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1]
)
ulp_id = "ulp_id"
test_user_full_name = "Test User"
test_nfc_id = "test_nfc_id"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.NFC_CARD_SCANNED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}},
)
new_camera = doorbell.model_copy()
new_camera.last_nfc_card_scanned_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_keyring = Mock()
mock_keyring.registry_id = test_nfc_id
mock_keyring.registry_type = "nfc"
mock_keyring.ulp_user = ulp_id
ufp.api.bootstrap.keyrings.add(mock_keyring)
mock_ulp_user = Mock()
mock_ulp_user.ulp_id = ulp_id
mock_ulp_user.full_name = test_user_full_name
mock_ulp_user.status = "ACTIVE"
ufp.api.bootstrap.ulp_users.add(mock_ulp_user)
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["nfc_id"] == "test_nfc_id"
assert state.attributes["full_name"] == test_user_full_name
unsub()
async def test_doorbell_nfc_scanned_ulpusr_deactivated(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell NFC scanned event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1]
)
ulp_id = "ulp_id"
test_user_full_name = "Test User"
test_nfc_id = "test_nfc_id"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.NFC_CARD_SCANNED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}},
)
new_camera = doorbell.model_copy()
new_camera.last_nfc_card_scanned_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_keyring = Mock()
mock_keyring.registry_id = test_nfc_id
mock_keyring.registry_type = "nfc"
mock_keyring.ulp_user = ulp_id
ufp.api.bootstrap.keyrings.add(mock_keyring)
mock_ulp_user = Mock()
mock_ulp_user.ulp_id = ulp_id
mock_ulp_user.full_name = test_user_full_name
mock_ulp_user.status = "DEACTIVATED"
ufp.api.bootstrap.ulp_users.add(mock_ulp_user)
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["nfc_id"] == "test_nfc_id"
assert state.attributes["full_name"] == "Test User"
assert state.attributes["user_status"] == "DEACTIVATED"
unsub()
async def test_doorbell_nfc_scanned_no_ulpusr(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell NFC scanned event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1]
)
ulp_id = "ulp_id"
test_nfc_id = "test_nfc_id"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.NFC_CARD_SCANNED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}},
)
new_camera = doorbell.model_copy()
new_camera.last_nfc_card_scanned_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_keyring = Mock()
mock_keyring.registry_id = test_nfc_id
mock_keyring.registry_type = "nfc"
mock_keyring.ulp_user = ulp_id
ufp.api.bootstrap.keyrings.add(mock_keyring)
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["nfc_id"] == "test_nfc_id"
assert state.attributes["full_name"] == ""
unsub()
async def test_doorbell_nfc_scanned_no_keyring(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell NFC scanned event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1]
)
test_nfc_id = "test_nfc_id"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.NFC_CARD_SCANNED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}},
)
new_camera = doorbell.model_copy()
new_camera.last_nfc_card_scanned_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["nfc_id"] == "test_nfc_id"
assert state.attributes["full_name"] == ""
unsub()
async def test_doorbell_fingerprint_identified(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell fingerprint identified event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2]
)
ulp_id = "ulp_id"
test_user_full_name = "Test User"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.FINGERPRINT_IDENTIFIED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"fingerprint": {"ulp_id": ulp_id}},
)
new_camera = doorbell.model_copy()
new_camera.last_fingerprint_identified_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_ulp_user = Mock()
mock_ulp_user.ulp_id = ulp_id
mock_ulp_user.full_name = test_user_full_name
mock_ulp_user.status = "ACTIVE"
ufp.api.bootstrap.ulp_users.add(mock_ulp_user)
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["ulp_id"] == ulp_id
assert state.attributes["full_name"] == test_user_full_name
unsub()
async def test_doorbell_fingerprint_identified_user_deactivated(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell fingerprint identified event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2]
)
ulp_id = "ulp_id"
test_user_full_name = "Test User"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.FINGERPRINT_IDENTIFIED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"fingerprint": {"ulp_id": ulp_id}},
)
new_camera = doorbell.model_copy()
new_camera.last_fingerprint_identified_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_ulp_user = Mock()
mock_ulp_user.ulp_id = ulp_id
mock_ulp_user.full_name = test_user_full_name
mock_ulp_user.status = "DEACTIVATED"
ufp.api.bootstrap.ulp_users.add(mock_ulp_user)
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["ulp_id"] == ulp_id
assert state.attributes["full_name"] == "Test User"
assert state.attributes["user_status"] == "DEACTIVATED"
unsub()
async def test_doorbell_fingerprint_identified_no_user(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell fingerprint identified event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2]
)
ulp_id = "ulp_id"
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.FINGERPRINT_IDENTIFIED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"fingerprint": {"ulp_id": ulp_id}},
)
new_camera = doorbell.model_copy()
new_camera.last_fingerprint_identified_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["ulp_id"] == ulp_id
assert state.attributes["full_name"] == ""
unsub()
async def test_doorbell_fingerprint_not_identified(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test a doorbell fingerprint identified event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
event = Event(
model=ModelType.EVENT,
id="test_event_id",
type=EventType.FINGERPRINT_IDENTIFIED,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={"fingerprint": {}},
)
new_camera = doorbell.model_copy()
new_camera.last_fingerprint_identified_event_id = "test_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes["ulp_id"] == ""
unsub()
async def test_vehicle_detection_basic(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test basic vehicle detection event with thumbnails."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with vehicle thumbnail
event = Event(
model=ModelType.EVENT,
id="test_vehicle_event_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 95,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id",
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_vehicle_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received vehicle detection event
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_vehicle_event_id"
assert state.attributes["confidence"] == 95
assert "clock_best_wall" in state.attributes
assert "license_plate" not in state.attributes
unsub()
async def test_vehicle_detection_with_lpr_ufp6(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test vehicle detection with license plate recognition (UFP 6.0+ format)."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with vehicle thumbnail and LPR in group.matched_name (UFP 6.0+)
event = Event(
model=ModelType.EVENT,
id="test_lpr_event_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 98,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id",
"group": {
"id": "lpr_group_1",
"matched_name": "ABC123",
"confidence": 95,
},
"attributes": {
"color": {"val": "blue", "confidence": 90},
"vehicle_type": {"val": "sedan", "confidence": 85},
},
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_lpr_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received vehicle detection event
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_lpr_event_id"
assert state.attributes["confidence"] == 98
assert state.attributes["license_plate"] == "ABC123"
assert "attributes" in state.attributes
assert state.attributes["attributes"]["color"]["val"] == "blue"
assert state.attributes["attributes"]["vehicleType"]["val"] == "sedan"
unsub()
async def test_vehicle_detection_with_lpr_legacy(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test vehicle detection with license plate recognition (legacy format)."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with vehicle thumbnail and LPR in name field (legacy)
event = Event(
model=ModelType.EVENT,
id="test_lpr_legacy_event_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 92,
"clock_best_wall": fixed_now,
"name": "XYZ789",
"cropped_id": "test_thumb_id",
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_lpr_legacy_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received vehicle detection event
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_lpr_legacy_event_id"
assert state.attributes["confidence"] == 92
assert state.attributes["license_plate"] == "XYZ789"
unsub()
async def test_vehicle_detection_multiple_thumbnails(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test vehicle detection with multiple thumbnails - should pick best LPR."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with multiple vehicle thumbnails
# best should be highest confidence LPR
event = Event(
model=ModelType.EVENT,
id="test_multi_thumbnail_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 70,
"clock_best_wall": fixed_now - timedelta(seconds=2),
"cropped_id": "test_thumb_id",
},
{
"type": "vehicle",
"confidence": 85,
"clock_best_wall": fixed_now - timedelta(seconds=1),
"cropped_id": "test_thumb_id_2",
"group": {
"id": "lpr_group_2",
"matched_name": "LOW_CONF",
"confidence": 85,
},
},
{
"type": "vehicle",
"confidence": 99,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id_3",
"group": {
"id": "lpr_group_3",
"matched_name": "BEST_LPR",
"confidence": 99,
},
},
{
"type": "person", # Should be ignored
"confidence": 100,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id_person",
},
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_multi_thumbnail_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received vehicle detection event with highest confidence LPR
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_multi_thumbnail_id"
assert state.attributes["confidence"] == 99
assert state.attributes["license_plate"] == "BEST_LPR"
unsub()
async def test_vehicle_detection_no_thumbnails(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test vehicle detection event without thumbnails - should not fire."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event without detected_thumbnails
event = Event(
model=ModelType.EVENT,
id="test_no_thumbnails_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_no_thumbnails_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer to expire
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should NOT have received any events (no vehicle thumbnails)
assert len(events) == 0
unsub()
async def test_vehicle_detection_timer_reset_on_new_thumbnail(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test that timer resets when new thumbnails arrive for same event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with one thumbnail
event = Event(
model=ModelType.EVENT,
id="test_timer_reset_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 80,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id_1",
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_timer_reset_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Wait briefly (timer hasn't expired yet)
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY / 2)
await hass.async_block_till_done()
# No event yet (timer hasn't expired)
assert len(events) == 0
# Update with better thumbnail - should reset timer
event.metadata = {
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 80,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id_1",
},
{
"type": "vehicle",
"confidence": 95,
"clock_best_wall": fixed_now + timedelta(seconds=1),
"cropped_id": "test_thumb_id_2",
"group": {
"id": "lpr_group_4",
"matched_name": "BETTER_LPR",
"confidence": 95,
},
},
]
}
ufp.api.bootstrap.events = {event.id: event}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Still no event (timer extended)
assert len(events) == 0
# Wait for timer to expire
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Now should have the event with the better LPR
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes["confidence"] == 95
assert state.attributes["license_plate"] == "BETTER_LPR"
unsub()
async def test_vehicle_detection_new_event_cancels_timer(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test that new event cancels timer for previous event."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create first event
event1 = Event(
model=ModelType.EVENT,
id="test_event_1",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=5),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 80,
"clock_best_wall": fixed_now - timedelta(seconds=4),
"cropped_id": "test_thumb_id",
"group": {
"id": "lpr_group_5",
"matched_name": "FIRST",
"confidence": 80,
},
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_event_1"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event1.id: event1}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event1
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Wait briefly (timer hasn't expired yet)
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY / 2)
await hass.async_block_till_done()
# No event yet
assert len(events) == 0
# Send new event - should fire first event immediately
event2 = Event(
model=ModelType.EVENT,
id="test_event_2",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 95,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id",
"group": {
"id": "lpr_group_6",
"matched_name": "SECOND",
"confidence": 95,
},
}
]
},
)
new_camera.last_smart_detect_event_id = "test_event_2"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event2.id: event2}
mock_msg.new_obj = event2
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Wait for second event's timer
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have two events - first fired immediately when second arrived
assert len(events) == 2
# First event fired immediately when second event arrived
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_event_1"
assert state.attributes["license_plate"] == "FIRST"
# Second event fired after timer
state = events[1].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_event_2"
assert state.attributes["license_plate"] == "SECOND"
unsub()
async def test_vehicle_detection_timer_cleanup_on_remove(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test that pending timer is cancelled when entity is removed."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
# Create event with vehicle thumbnail
event = Event(
model=ModelType.EVENT,
id="test_cleanup_event_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 90,
"clock_best_wall": fixed_now,
"cropped_id": "test_cleanup_thumb_id",
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_cleanup_event_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Timer is now pending - remove the entity before it fires
await remove_entities(hass, ufp, [doorbell])
await hass.async_block_till_done()
# Wait past when timer would have fired
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Entity should be gone and no event should have fired
state = hass.states.get(entity_id)
assert state is None
async def test_vehicle_detection_refire_on_lpr_data(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test that event refires when LPR data arrives after initial detection."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with vehicle thumbnail but NO LPR data
event = Event(
model=ModelType.EVENT,
id="test_refire_lpr_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 85,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id",
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_refire_lpr_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer to expire - first event should fire without LPR
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received first event without LPR
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_refire_lpr_id"
assert state.attributes["confidence"] == 85
assert "license_plate" not in state.attributes
# Now LPR data arrives for the same event
event.metadata = {
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 85,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id",
},
{
"type": "vehicle",
"confidence": 95,
"clock_best_wall": fixed_now + timedelta(seconds=1),
"cropped_id": "test_thumb_id_lpr",
"group": {
"id": "lpr_group",
"matched_name": "ABC123",
"confidence": 95,
},
},
]
}
ufp.api.bootstrap.events = {event.id: event}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the new timer to expire
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received second event WITH LPR data
assert len(events) == 2
state = events[1].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_refire_lpr_id"
assert state.attributes["confidence"] == 95
assert state.attributes["license_plate"] == "ABC123"
unsub()
async def test_vehicle_detection_no_refire_same_data(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test that event does NOT refire when same data arrives again."""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.EVENT, 7, 7)
events: list[HAEvent] = []
@callback
def _capture_event(event: HAEvent) -> None:
events.append(event)
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
)
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
# Create event with vehicle thumbnail
event = Event(
model=ModelType.EVENT,
id="test_no_refire_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
metadata={
"detected_thumbnails": [
{
"type": "vehicle",
"confidence": 90,
"clock_best_wall": fixed_now,
"cropped_id": "test_thumb_id",
"group": {
"id": "lpr_group",
"matched_name": "XYZ789",
"confidence": 90,
},
}
]
},
)
new_camera = doorbell.model_copy()
new_camera.last_smart_detect_event_id = "test_no_refire_id"
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
# Wait for the timer to expire
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should have received one event
assert len(events) == 1
state = events[0].data["new_state"]
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_no_refire_id"
assert state.attributes["license_plate"] == "XYZ789"
# Send the same event again with identical data
ufp.ws_msg(mock_msg)
await asyncio.sleep(TEST_VEHICLE_EVENT_DELAY * 2)
await hass.async_block_till_done()
# Should NOT have received another event (same data)
assert len(events) == 1
unsub()
async def test_aiport_no_event_entities(
hass: HomeAssistant,
ufp: MockUFPFixture,
aiport: AiPort,
) -> None:
"""Test that AI Port devices do not create camera-specific event entities."""
await init_entry(hass, ufp, [aiport])
# AI Port should not create any camera-specific event entities
# (doorbell, motion, etc.)
assert_entity_counts(hass, Platform.EVENT, 0, 0)
async def test_motion_detection_event(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""The motion event entity fires from the public events websocket."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
ufp.events_msg(
ProtectEvent(
id="motion-1",
type=EventType.MOTION,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
),
EventChange.STARTED,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.attributes["event_type"] == "motion"
assert state.attributes[ATTR_EVENT_ID] == "motion-1"
@pytest.mark.parametrize(
"event_type",
[
pytest.param(EventType.SMART_DETECT, id="zone"),
pytest.param(EventType.SMART_DETECT_LINE, id="line"),
pytest.param(EventType.SMART_DETECT_LOITER, id="loiter"),
],
)
async def test_smart_detection_event(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
event_type: EventType,
) -> None:
"""The smart-detection event entity fires per object type with the full type set.
smartDetectZone, smartDetectLine, and smartDetectLoiterZone events all carry
smart detections, so a standalone line-crossing or loitering event must fire
the entity too.
"""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
ufp.events_msg(
ProtectEvent(
id="smart-1",
type=event_type,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
smart_detect_types=(
SmartDetectObjectType.PERSON,
SmartDetectObjectType.VEHICLE,
),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
unsub()
# One fire per surfaced type, each carrying the full co-detected set.
fired = [event.data["new_state"].attributes["event_type"] for event in events]
assert fired == ["person", "vehicle"]
last = events[-1].data["new_state"]
assert last.attributes[ATTR_EVENT_ID] == "smart-1"
assert last.attributes[ATTR_SMART_DETECT_TYPES] == ["person", "vehicle"]
async def test_sound_detection_event(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""The sound-detection event entity fires for audio types (slugged event type)."""
doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE]
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
ufp.events_msg(
ProtectEvent(
id="audio-1",
type=EventType.SMART_AUDIO_DETECT,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.SMOKE,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.attributes["event_type"] == "smoke"
assert state.attributes[ATTR_EVENT_ID] == "audio-1"
async def test_sound_detection_event_late_type(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Audio types arrive on a later update, not at start; fire once when they appear."""
doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE]
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
base = {
"id": "audio-1",
"type": EventType.SMART_AUDIO_DETECT,
"channel": ProtectEventChannel.DETECTION,
"device_id": doorbell.id,
"device_mac": doorbell.mac,
"start": fixed_now - timedelta(seconds=1),
}
# Start carries no type yet -> nothing fires.
ufp.events_msg(
ProtectEvent(**base, end=None, smart_detect_types=()), EventChange.STARTED
)
await hass.async_block_till_done()
assert events == []
# The type appears on a later update -> fires once.
ufp.events_msg(
ProtectEvent(
**base, end=None, smart_detect_types=(SmartDetectObjectType.SMOKE,)
),
EventChange.UPDATED,
)
await hass.async_block_till_done()
# A further update for the same type is deduped (no re-fire).
ufp.events_msg(
ProtectEvent(
**base, end=fixed_now, smart_detect_types=(SmartDetectObjectType.SMOKE,)
),
EventChange.UPDATED,
)
await hass.async_block_till_done()
unsub()
fired = [event.data["new_state"].attributes["event_type"] for event in events]
assert fired == ["smoke"]
async def test_sound_detection_absent_without_audio_types(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
entity_registry: EntityRegistry,
) -> None:
"""Object smart-detect without audio support creates no sound-detection entity."""
doorbell.feature_flags.smart_detect_audio_types = []
await init_entry(hass, ufp, [doorbell])
smart = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
sound = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection")
_, smart_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, smart
)
_, sound_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, sound
)
# object detection stays, audio (sound) detection is gated out
assert entity_registry.async_get(smart_id) is not None
assert entity_registry.async_get(sound_id) is None
async def test_detection_event_removed_change_ignored(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""A REMOVED (eviction) change does not fire a detection event."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
ufp.events_msg(
ProtectEvent(
id="ev-removed",
type=EventType.SMART_DETECT,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
smart_detect_types=(SmartDetectObjectType.PERSON,),
),
EventChange.REMOVED,
)
await hass.async_block_till_done()
unsub()
assert events == []
async def test_doorbell_ring_dedup_across_dispatches(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""A ring fires once even though start, update and end are all dispatched."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0]
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
base = {
"id": "ring-1",
"type": EventType.RING,
"channel": ProtectEventChannel.DETECTION,
"device_id": doorbell.id,
"device_mac": doorbell.mac,
"start": fixed_now - timedelta(seconds=1),
}
for change, end in (
(EventChange.STARTED, None),
(EventChange.UPDATED, None),
(EventChange.ENDED, fixed_now),
):
ufp.events_msg(ProtectEvent(**base, end=end), change)
await hass.async_block_till_done()
unsub()
assert len(events) == 1
def test_detection_event_types_have_translations() -> None:
"""Every category detection event type has a strings.json state label.
Guards against a uiprotect enum addition slugging into ``event_types`` (and
firing) without a matching translation label.
"""
strings = json.loads(
(
Path(__file__).parents[3]
/ "homeassistant/components/unifiprotect/strings.json"
).read_text()
)
event_states = strings["entity"]["event"]
for key in ("motion_detection", "smart_detection", "sound_detection"):
description = next(d for d in EVENT_DESCRIPTIONS if d.key == key)
labels = event_states[key]["state_attributes"]["event_type"]["state"]
missing = [t for t in description.event_types or () if t not in labels]
assert not missing, f"{key} missing event_type labels: {missing}"
async def test_smart_detection_event_interleaved_dedup(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Two overlapping same-category events whose dispatches interleave don't re-fire."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
common = {
"type": EventType.SMART_DETECT,
"channel": ProtectEventChannel.DETECTION,
"device_id": doorbell.id,
"device_mac": doorbell.mac,
"start": fixed_now - timedelta(seconds=1),
"end": None,
}
# Event A and B overlap; A re-dispatches after B started.
ufp.events_msg(
ProtectEvent(
id="evt-a", smart_detect_types=(SmartDetectObjectType.PERSON,), **common
),
EventChange.STARTED,
)
ufp.events_msg(
ProtectEvent(
id="evt-b", smart_detect_types=(SmartDetectObjectType.VEHICLE,), **common
),
EventChange.STARTED,
)
ufp.events_msg(
ProtectEvent(
id="evt-a", smart_detect_types=(SmartDetectObjectType.PERSON,), **common
),
EventChange.UPDATED,
)
await hass.async_block_till_done()
unsub()
# A's person is not re-fired when its update arrives after B's dispatch.
fired = [event.data["new_state"].attributes["event_type"] for event in events]
assert fired == ["person", "vehicle"]
async def test_detection_event_dedup_is_bounded(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""The fire-dedup tracker is bounded; distinct events keep firing."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
for index in range(20):
ufp.events_msg(
ProtectEvent(
id=f"motion-{index}",
type=EventType.MOTION,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
),
EventChange.STARTED,
)
await hass.async_block_till_done()
unsub()
assert len(events) == 20
async def test_detection_event_dedup_evicts_oldest(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Exceeding the dedup cap evicts the oldest event id, letting it refire.
An unbounded tracker would also pass ``test_detection_event_dedup_is_bounded``
(it only sends distinct ids), so this replays an id that should have aged out.
"""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
_, entity_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, description
)
events: list[HAEvent] = []
@callback
def _capture(event: HAEvent) -> None:
events.append(event)
unsub = async_track_state_change_event(hass, entity_id, _capture)
def _send(index: int) -> None:
ufp.events_msg(
ProtectEvent(
id=f"motion-{index}",
type=EventType.MOTION,
channel=ProtectEventChannel.DETECTION,
device_id=doorbell.id,
device_mac=doorbell.mac,
start=fixed_now - timedelta(seconds=1),
end=fixed_now,
),
EventChange.STARTED,
)
# A (cap + 1)th distinct id evicts the oldest tracked id ("motion-0").
for index in range(_MAX_TRACKED_EVENTS + 1):
_send(index)
await hass.async_block_till_done()
assert len(events) == _MAX_TRACKED_EVENTS + 1
# Replaying the evicted id fires again; a bug that never evicts would dedup it.
_send(0)
await hass.async_block_till_done()
unsub()
assert len(events) == _MAX_TRACKED_EVENTS + 2
async def test_event_entities_unavailable_on_events_ws_disconnect(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
) -> None:
"""Public event entities follow the events websocket they fire from."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
ring = next(d for d in EVENT_DESCRIPTIONS if d.key == "doorbell")
_, ring_id = await ids_from_device_description(hass, Platform.EVENT, doorbell, ring)
motion = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
_, motion_id = await ids_from_device_description(
hass, Platform.EVENT, doorbell, motion
)
assert hass.states.get(ring_id).state != STATE_UNAVAILABLE
assert hass.states.get(motion_id).state != STATE_UNAVAILABLE
assert ufp.events_ws_state_subscription is not None
ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED)
await hass.async_block_till_done()
assert hass.states.get(ring_id).state == STATE_UNAVAILABLE
assert hass.states.get(motion_id).state == STATE_UNAVAILABLE
ufp.events_ws_state_subscription(WebsocketState.CONNECTED)
await hass.async_block_till_done()
assert hass.states.get(ring_id).state != STATE_UNAVAILABLE
assert hass.states.get(motion_id).state != STATE_UNAVAILABLE