Add UniFi Protect camera motion sensors and ThumbnailProxyView (#63696)

This commit is contained in:
Christopher Bailey 2022-01-08 18:51:49 -05:00 committed by GitHub
parent 71208b2ebb
commit 0232021f5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 787 additions and 54 deletions

View file

@ -33,6 +33,7 @@ from .const import (
PLATFORMS,
)
from .data import ProtectData
from .views import ThumbnailProxyView
_LOGGER = logging.getLogger(__name__)
@ -82,6 +83,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_service
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
hass.http.register_view(ThumbnailProxyView(hass))
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)

View file

@ -6,14 +6,10 @@ from dataclasses import dataclass
import logging
from typing import Any
from pyunifiprotect.data import NVR, Camera, Light, Sensor
from pyunifiprotect.data import NVR, Camera, Event, Light, Sensor
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_DOOR,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OCCUPANCY,
DEVICE_CLASS_PROBLEM,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@ -25,7 +21,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity, ProtectNVREntity, async_all_device_entities
from .entity import (
EventThumbnailMixin,
ProtectDeviceEntity,
ProtectNVREntity,
async_all_device_entities,
)
from .models import ProtectRequiredKeysMixin
from .utils import get_nested_attr
@ -51,7 +52,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key=_KEY_DOORBELL,
name="Doorbell",
device_class=DEVICE_CLASS_OCCUPANCY,
device_class=BinarySensorDeviceClass.OCCUPANCY,
icon="mdi:doorbell-video",
ufp_required_field="feature_flags.has_chime",
ufp_value="is_ringing",
@ -74,7 +75,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key=_KEY_MOTION,
name="Motion Detected",
device_class=DEVICE_CLASS_MOTION,
device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_pir_motion_detected",
),
)
@ -83,29 +84,39 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key=_KEY_DOOR,
name="Door",
device_class=DEVICE_CLASS_DOOR,
device_class=BinarySensorDeviceClass.DOOR,
ufp_value="is_opened",
),
ProtectBinaryEntityDescription(
key=_KEY_BATTERY_LOW,
name="Battery low",
device_class=DEVICE_CLASS_BATTERY,
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="battery_status.is_low",
),
ProtectBinaryEntityDescription(
key=_KEY_MOTION,
name="Motion Detected",
device_class=DEVICE_CLASS_MOTION,
device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_motion_detected",
),
)
MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key=_KEY_MOTION,
name="Motion",
device_class=BinarySensorDeviceClass.MOTION,
ufp_value="is_motion_detected",
),
)
DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription(
key=_KEY_DISK_HEALTH,
name="Disk {index} Health",
device_class=DEVICE_CLASS_PROBLEM,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
@ -125,11 +136,29 @@ async def async_setup_entry(
light_descs=LIGHT_SENSORS,
sense_descs=SENSE_SENSORS,
)
entities += _async_motion_entities(data)
entities += _async_nvr_entities(data)
async_add_entities(entities)
@callback
def _async_motion_entities(
data: ProtectData,
) -> list[ProtectDeviceEntity]:
entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values():
for description in MOTION_SENSORS:
entities.append(ProtectEventBinarySensor(data, device, description))
_LOGGER.debug(
"Adding binary sensor entity %s for %s",
description.name,
device.name,
)
return entities
@callback
def _async_nvr_entities(
data: ProtectData,
@ -173,9 +202,11 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity):
if key == _KEY_DARK:
return attrs
if isinstance(self.device, Camera):
if key == _KEY_DOORBELL:
assert isinstance(self.device, Camera)
attrs[ATTR_LAST_TRIP_TIME] = self.device.last_ring
elif key == _KEY_MOTION:
attrs[ATTR_LAST_TRIP_TIME] = self.device.last_motion
elif isinstance(self.device, Sensor):
if key in (_KEY_MOTION, _KEY_DOOR):
if key == _KEY_MOTION:
@ -199,9 +230,11 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity):
self._attr_is_on = get_nested_attr(
self.device, self.entity_description.ufp_value
)
self._attr_extra_state_attributes = (
self._async_update_extra_attrs_from_protect()
)
attrs = self.extra_state_attributes or {}
self._attr_extra_state_attributes = {
**attrs,
**self._async_update_extra_attrs_from_protect(),
}
class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
@ -233,3 +266,27 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
disk = disks[self._index]
self._attr_is_on = not disk.healthy
self._attr_extra_state_attributes = {ATTR_MODEL: disk.model}
class ProtectEventBinarySensor(EventThumbnailMixin, ProtectDeviceBinarySensor):
"""A UniFi Protect Device Binary Sensor with access tokens."""
def __init__(
self,
data: ProtectData,
device: Camera,
description: ProtectBinaryEntityDescription,
) -> None:
"""Init a binary sensor that uses access tokens."""
self.device: Camera = device
super().__init__(data, description=description)
@callback
def _async_get_event(self) -> Event | None:
"""Get event from Protect device."""
event: Event | None = None
if self.device.is_motion_detected and self.device.last_motion_event is not None:
event = self.device.last_motion_event
return event

View file

@ -6,6 +6,8 @@ from homeassistant.const import Platform
DOMAIN = "unifiprotect"
ATTR_EVENT_SCORE = "event_score"
ATTR_EVENT_THUMB = "event_thumbnail"
ATTR_WIDTH = "width"
ATTR_HEIGHT = "height"
ATTR_FPS = "fps"

View file

@ -1,7 +1,7 @@
"""Base class for protect data."""
from __future__ import annotations
import collections
from collections import deque
from collections.abc import Generator, Iterable
from datetime import timedelta
import logging
@ -43,7 +43,7 @@ class ProtectData:
self._unsub_websocket: CALLBACK_TYPE | None = None
self.last_update_success = False
self.access_tokens: dict[str, collections.deque] = {}
self.access_tokens: dict[str, deque] = {}
self.api = protect
@property
@ -177,3 +177,10 @@ class ProtectData:
_LOGGER.debug("Updating device: %s", device_id)
for update_callback in self._subscriptions[device_id]:
update_callback()
@callback
def async_get_or_create_access_tokens(self, entity_id: str) -> deque:
"""Wrap access_tokens to automatically create underlying data structure if missing."""
if entity_id not in self.access_tokens:
self.access_tokens[entity_id] = deque([], 2)
return self.access_tokens[entity_id]

View file

@ -1,11 +1,18 @@
"""Shared Entity definition for UniFi Protect Integration."""
from __future__ import annotations
from collections import deque
from collections.abc import Sequence
from datetime import datetime, timedelta
import hashlib
import logging
from random import SystemRandom
from typing import Any, Final
from urllib.parse import urlencode
from pyunifiprotect.data import (
Camera,
Event,
Light,
ModelType,
ProtectAdoptableDeviceModel,
@ -19,11 +26,21 @@ from homeassistant.core import callback
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
from .const import (
ATTR_EVENT_SCORE,
ATTR_EVENT_THUMB,
DEFAULT_ATTRIBUTION,
DEFAULT_BRAND,
DOMAIN,
)
from .data import ProtectData
from .models import ProtectRequiredKeysMixin
from .utils import get_nested_attr
from .views import ThumbnailProxyView
EVENT_UPDATE_TOKENS = "unifiprotect_update_tokens"
TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=1)
_RND: Final = SystemRandom()
_LOGGER = logging.getLogger(__name__)
@ -203,3 +220,99 @@ class ProtectNVREntity(ProtectDeviceEntity):
self.device = self.data.api.bootstrap.nvr
self._attr_available = self.data.last_update_success
class AccessTokenMixin(Entity):
"""Adds access_token attribute and provides access tokens for use for anonymous views."""
@property
def access_tokens(self) -> deque[str]:
"""Get valid access_tokens for current entity."""
assert isinstance(self, ProtectDeviceEntity)
return self.data.async_get_or_create_access_tokens(self.entity_id)
@callback
def _async_update_and_write_token(self, now: datetime) -> None:
_LOGGER.debug("Updating access tokens for %s", self.entity_id)
self.async_update_token()
self.async_write_ha_state()
@callback
def async_update_token(self) -> None:
"""Update the used token."""
self.access_tokens.append(
hashlib.sha256(_RND.getrandbits(256).to_bytes(32, "little")).hexdigest()
)
@callback
def async_cleanup_tokens(self) -> None:
"""Clean up any remaining tokens on removal."""
assert isinstance(self, ProtectDeviceEntity)
if self.entity_id in self.data.access_tokens:
del self.data.access_tokens[self.entity_id]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.
Injects callbacks to update access tokens automatically
"""
await super().async_added_to_hass()
self.async_update_token()
self.async_on_remove(
self.hass.helpers.event.async_track_time_interval(
self._async_update_and_write_token, TOKEN_CHANGE_INTERVAL
)
)
self.async_on_remove(self.async_cleanup_tokens)
class EventThumbnailMixin(AccessTokenMixin):
"""Adds motion event attributes to sensor."""
def __init__(self, *args: Any, **kwarg: Any) -> None:
"""Init an sensor that has event thumbnails."""
super().__init__(*args, **kwarg)
self._event: Event | None = None
@callback
def _async_get_event(self) -> Event | None:
"""Get event from Protect device.
To be overridden by child classes.
"""
raise NotImplementedError()
@callback
def _async_thumbnail_extra_attrs(self) -> dict[str, Any]:
# Camera motion sensors with object detection
attrs: dict[str, Any] = {
ATTR_EVENT_SCORE: 0,
ATTR_EVENT_THUMB: None,
}
if self._event is None:
return attrs
attrs[ATTR_EVENT_SCORE] = self._event.score
if len(self.access_tokens) > 0:
params = urlencode(
{"entity_id": self.entity_id, "token": self.access_tokens[-1]}
)
attrs[ATTR_EVENT_THUMB] = (
ThumbnailProxyView.url.format(event_id=self._event.id) + f"?{params}"
)
return attrs
@callback
def _async_update_device_from_protect(self) -> None:
assert isinstance(self, ProtectDeviceEntity)
super()._async_update_device_from_protect() # type: ignore
self._event = self._async_get_event()
attrs = self.extra_state_attributes or {}
self._attr_extra_state_attributes = {
**attrs,
**self._async_thumbnail_extra_attrs(),
}

View file

@ -6,6 +6,9 @@
"requirements": [
"pyunifiprotect==1.5.3"
],
"dependencies": [
"http"
],
"codeowners": [
"@briis",
"@AngellusMortis",

View file

@ -6,10 +6,11 @@ from datetime import datetime, timedelta
import logging
from typing import Any
from pyunifiprotect.data import NVR
from pyunifiprotect.data import NVR, Camera, Event
from pyunifiprotect.data.base import ProtectAdoptableDeviceModel
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
@ -19,13 +20,6 @@ from homeassistant.const import (
DATA_BYTES,
DATA_RATE_BYTES_PER_SECOND,
DATA_RATE_MEGABITS_PER_SECOND,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_VOLTAGE,
ELECTRIC_POTENTIAL_VOLT,
LIGHT_LUX,
PERCENTAGE,
@ -39,11 +33,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity, ProtectNVREntity, async_all_device_entities
from .entity import (
EventThumbnailMixin,
ProtectDeviceEntity,
ProtectNVREntity,
async_all_device_entities,
)
from .models import ProtectRequiredKeysMixin
from .utils import get_nested_attr
_LOGGER = logging.getLogger(__name__)
DETECTED_OBJECT_NONE = "none"
DEVICE_CLASS_DETECTION = "unifiprotect__detection"
@dataclass
@ -82,12 +83,14 @@ _KEY_RES_4K = "resolution_4K"
_KEY_RES_FREE = "resolution_free"
_KEY_CAPACITY = "record_capacity"
_KEY_OBJECT = "detected_object"
ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription(
key=_KEY_UPTIME,
name="Uptime",
icon="mdi:clock",
device_class=DEVICE_CLASS_TIMESTAMP,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
ufp_value="up_since",
@ -96,7 +99,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_BLE,
name="Bluetooth Signal Strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
@ -117,7 +120,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_WIFI,
name="WiFi Signal Strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
@ -130,7 +133,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription(
key=_KEY_OLDEST,
name="Oldest Recording",
device_class=DEVICE_CLASS_TIMESTAMP,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="stats.video.recording_start",
),
@ -154,7 +157,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription(
key=_KEY_VOLTAGE,
name="Voltage",
device_class=DEVICE_CLASS_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
@ -192,7 +195,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_BATTERY,
name="Battery Level",
native_unit_of_measurement=PERCENTAGE,
device_class=DEVICE_CLASS_BATTERY,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
ufp_value="battery_status.percentage",
@ -201,7 +204,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_LIGHT,
name="Light Level",
native_unit_of_measurement=LIGHT_LUX,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
ufp_value="stats.light.value",
),
@ -209,7 +212,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_HUMIDITY,
name="Humidity Level",
native_unit_of_measurement=PERCENTAGE,
device_class=DEVICE_CLASS_HUMIDITY,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
ufp_value="stats.humidity.value",
),
@ -217,7 +220,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_TEMP,
name="Temperature",
native_unit_of_measurement=TEMP_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
ufp_value="stats.temperature.value",
),
@ -228,7 +231,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_UPTIME,
name="Uptime",
icon="mdi:clock",
device_class=DEVICE_CLASS_TIMESTAMP,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="up_since",
),
@ -328,7 +331,7 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
key=_KEY_CPU_TEMP,
name="CPU Temperature",
native_unit_of_measurement=TEMP_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
@ -346,6 +349,14 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
),
)
MOTION_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription(
key=_KEY_OBJECT,
name="Detected Object",
device_class=DEVICE_CLASS_DETECTION,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -361,11 +372,32 @@ async def async_setup_entry(
camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS,
sense_descs=SENSE_SENSORS,
)
entities += _async_motion_entities(data)
entities += _async_nvr_entities(data)
async_add_entities(entities)
@callback
def _async_motion_entities(
data: ProtectData,
) -> list[ProtectDeviceEntity]:
entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values():
if not device.feature_flags.has_smart_detect:
continue
for description in MOTION_SENSORS:
entities.append(ProtectEventSensor(data, device, description))
_LOGGER.debug(
"Adding sensor entity %s for %s",
description.name,
device.name,
)
return entities
@callback
def _async_nvr_entities(
data: ProtectData,
@ -415,7 +447,8 @@ class ProtectDeviceSensor(SensorValueMixin, ProtectDeviceEntity, SensorEntity):
def _async_update_device_from_protect(self) -> None:
super()._async_update_device_from_protect()
assert self.entity_description.ufp_value is not None
if self.entity_description.ufp_value is None:
return
value = get_nested_attr(self.device, self.entity_description.ufp_value)
self._attr_native_value = self._clean_sensor_value(value)
@ -451,3 +484,40 @@ class ProtectNVRSensor(SensorValueMixin, ProtectNVREntity, SensorEntity):
value = get_nested_attr(self.device, self.entity_description.ufp_value)
self._attr_native_value = self._clean_sensor_value(value)
class ProtectEventSensor(EventThumbnailMixin, ProtectDeviceSensor):
"""A UniFi Protect Device Sensor with access tokens."""
def __init__(
self,
data: ProtectData,
device: Camera,
description: ProtectSensorEntityDescription,
) -> None:
"""Init an sensor that uses access tokens."""
self.device: Camera = device
super().__init__(data, device, description)
@callback
def _async_get_event(self) -> Event | None:
"""Get event from Protect device."""
event: Event | None = None
if (
self.device.is_smart_detected
and self.device.last_smart_detect_event is not None
and len(self.device.last_smart_detect_event.smart_detect_types) > 0
):
event = self.device.last_smart_detect_event
return event
@callback
def _async_update_device_from_protect(self) -> None:
super()._async_update_device_from_protect()
if self._event is None:
self._attr_native_value = DETECTED_OBJECT_NONE
else:
self._attr_native_value = self._event.smart_detect_types[0].value

View file

@ -0,0 +1,91 @@
"""UniFi Protect Integration views."""
from __future__ import annotations
import collections
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import web
from pyunifiprotect.api import ProtectApiClient
from pyunifiprotect.exceptions import NvrError
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .data import ProtectData
_LOGGER = logging.getLogger(__name__)
def _404(message: Any) -> web.Response:
_LOGGER.error("Error on load thumbnail: %s", message)
return web.Response(status=HTTPStatus.NOT_FOUND)
class ThumbnailProxyView(HomeAssistantView):
"""View to proxy event thumbnails from UniFi Protect."""
requires_auth = False
url = "/api/ufp/thumbnail/{event_id}"
name = "api:ufp_thumbnail"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a thumbnail proxy view."""
self.hass = hass
self.data = hass.data[DOMAIN]
def _get_access_tokens(
self, entity_id: str
) -> tuple[collections.deque, ProtectApiClient] | None:
entries: list[ProtectData] = list(self.data.values())
for entry in entries:
if entity_id in entry.access_tokens:
return entry.access_tokens[entity_id], entry.api
return None
async def get(self, request: web.Request, event_id: str) -> web.Response:
"""Start a get request."""
entity_id: str | None = request.query.get("entity_id")
width: int | str | None = request.query.get("w")
height: int | str | None = request.query.get("h")
token: str | None = request.query.get("token")
if width is not None:
try:
width = int(width)
except ValueError:
return _404("Invalid width param")
if height is not None:
try:
height = int(height)
except ValueError:
return _404("Invalid height param")
access_tokens: list[str] = []
if entity_id is not None:
items = self._get_access_tokens(entity_id)
if items is None:
return _404(f"Could not find entity with entity_id {entity_id}")
access_tokens = list(items[0])
instance = items[1]
authenticated = request[KEY_AUTHENTICATED] or token in access_tokens
if not authenticated:
raise web.HTTPUnauthorized()
try:
thumbnail = await instance.get_event_thumbnail(
event_id, width=width, height=height
)
except NvrError as err:
return _404(err)
if thumbnail is None:
return _404("Event thumbnail not found")
return web.Response(body=thumbnail, content_type="image/jpeg")

View file

@ -37,6 +37,7 @@ class MockBootstrap:
sensors: dict[str, Any]
viewers: dict[str, Any]
liveviews: dict[str, Any]
events: dict[str, Any]
def reset_objects(self) -> None:
"""Reset all devices on bootstrap for tests."""
@ -45,6 +46,7 @@ class MockBootstrap:
self.sensors = {}
self.viewers = {}
self.liveviews = {}
self.events = {}
@dataclass
@ -83,7 +85,13 @@ def mock_old_nvr_fixture():
def mock_bootstrap_fixture(mock_nvr: NVR):
"""Mock Bootstrap fixture."""
return MockBootstrap(
nvr=mock_nvr, cameras={}, lights={}, sensors={}, viewers={}, liveviews={}
nvr=mock_nvr,
cameras={},
lights={},
sensors={},
viewers={},
liveviews={},
events={},
)

View file

@ -2,22 +2,29 @@
# pylint: disable=protected-access
from __future__ import annotations
from copy import copy
from datetime import datetime, timedelta
from unittest.mock import Mock
import pytest
from pyunifiprotect.data import Camera, Light
from pyunifiprotect.data.devices import Sensor
from pyunifiprotect.data import Camera, Event, EventType, Light, Sensor
from homeassistant.components.unifiprotect.binary_sensor import (
CAMERA_SENSORS,
LIGHT_SENSORS,
MOTION_SENSORS,
SENSE_SENSORS,
)
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
from homeassistant.components.unifiprotect.const import (
ATTR_EVENT_SCORE,
ATTR_EVENT_THUMB,
DEFAULT_ATTRIBUTION,
)
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_LAST_TRIP_TIME,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
@ -51,6 +58,7 @@ async def camera_fixture(
camera_obj.feature_flags.has_chime = True
camera_obj.last_ring = now - timedelta(hours=1)
camera_obj.is_dark = False
camera_obj.is_motion_detected = False
mock_entry.api.bootstrap.reset_objects()
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
@ -61,7 +69,7 @@ async def camera_fixture(
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3)
yield camera_obj
@ -117,6 +125,7 @@ async def camera_none_fixture(
camera_obj.name = "Test Camera"
camera_obj.feature_flags.has_chime = False
camera_obj.is_dark = False
camera_obj.is_motion_detected = False
mock_entry.api.bootstrap.reset_objects()
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
@ -127,7 +136,7 @@ async def camera_none_fixture(
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
assert_entity_counts(hass, Platform.BINARY_SENSOR, 1, 1)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2)
yield camera_obj
@ -219,6 +228,7 @@ async def test_binary_sensor_setup_camera_all(
assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1)
# Is Dark
description = CAMERA_SENSORS[1]
unique_id, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, camera, description
@ -233,6 +243,23 @@ async def test_binary_sensor_setup_camera_all(
assert state.state == STATE_OFF
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
# Motion
description = MOTION_SENSORS[0]
unique_id, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, camera, description
)
entity = entity_registry.async_get(entity_id)
assert entity
assert entity.unique_id == unique_id
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_SCORE] == 0
assert state.attributes[ATTR_EVENT_THUMB] is None
async def test_binary_sensor_setup_camera_none(
hass: HomeAssistant,
@ -281,3 +308,48 @@ async def test_binary_sensor_setup_sensor(
if index != 1:
assert state.attributes[ATTR_LAST_TRIP_TIME] == expected_trip_time
async def test_binary_sensor_update_motion(
hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime
):
"""Test binary_sensor motion entity."""
_, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, camera, MOTION_SENSORS[0]
)
event = Event(
id="test_event_id",
type=EventType.MOTION,
start=now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=camera.id,
)
new_bootstrap = copy(mock_entry.api.bootstrap)
new_camera = camera.copy()
new_camera.is_motion_detected = True
new_camera.last_motion_event_id = event.id
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = new_camera
new_bootstrap.cameras = {new_camera.id: new_camera}
new_bootstrap.events = {event.id: event}
mock_entry.api.bootstrap = new_bootstrap
mock_entry.api.ws_subscription(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_SCORE] == 100
assert state.attributes[ATTR_EVENT_THUMB].startswith(
f"/api/ufp/thumbnail/test_event_id?entity_id={entity_id}&token="
)

View file

@ -2,18 +2,26 @@
# pylint: disable=protected-access
from __future__ import annotations
from datetime import datetime
from copy import copy
from datetime import datetime, timedelta
from unittest.mock import Mock
import pytest
from pyunifiprotect.data import NVR, Camera, Event, Sensor
from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState
from pyunifiprotect.data.devices import Camera, Sensor
from pyunifiprotect.data.nvr import NVR
from pyunifiprotect.data.types import EventType, SmartDetectObjectType
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
from homeassistant.components.unifiprotect.const import (
ATTR_EVENT_SCORE,
ATTR_EVENT_THUMB,
DEFAULT_ATTRIBUTION,
)
from homeassistant.components.unifiprotect.sensor import (
ALL_DEVICES_SENSORS,
CAMERA_DISABLED_SENSORS,
CAMERA_SENSORS,
DETECTED_OBJECT_NONE,
MOTION_SENSORS,
NVR_DISABLED_SENSORS,
NVR_SENSORS,
SENSE_SENSORS,
@ -86,6 +94,8 @@ async def camera_fixture(
camera_obj.channels[1]._api = mock_entry.api
camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera"
camera_obj.feature_flags.has_smart_detect = True
camera_obj.is_smart_detected = False
camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000)
camera_obj.wifi_connection_state = WifiConnectionState(
signal_quality=100, signal_strength=-50
@ -108,7 +118,7 @@ async def camera_fixture(
await hass.async_block_till_done()
# 3 from all, 6 from camera, 12 NVR
assert_entity_counts(hass, Platform.SENSOR, 21, 13)
assert_entity_counts(hass, Platform.SENSOR, 22, 14)
yield camera_obj
@ -347,3 +357,64 @@ async def test_sensor_setup_camera(
assert state
assert state.state == "-50"
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
# Detected Object
unique_id, entity_id = ids_from_device_description(
Platform.SENSOR, camera, MOTION_SENSORS[0]
)
entity = entity_registry.async_get(entity_id)
assert entity
assert entity.unique_id == unique_id
state = hass.states.get(entity_id)
assert state
assert state.state == DETECTED_OBJECT_NONE
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_SCORE] == 0
assert state.attributes[ATTR_EVENT_THUMB] is None
async def test_sensor_update_motion(
hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime
):
"""Test sensor motion entity."""
_, entity_id = ids_from_device_description(
Platform.SENSOR, camera, MOTION_SENSORS[0]
)
event = Event(
id="test_event_id",
type=EventType.SMART_DETECT,
start=now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[SmartDetectObjectType.PERSON],
smart_detect_event_ids=[],
camera_id=camera.id,
)
new_bootstrap = copy(mock_entry.api.bootstrap)
new_camera = camera.copy()
new_camera.is_smart_detected = True
new_camera.last_smart_detect_event_id = event.id
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = new_camera
new_bootstrap.cameras = {new_camera.id: new_camera}
new_bootstrap.events = {event.id: event}
mock_entry.api.bootstrap = new_bootstrap
mock_entry.api.ws_subscription(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == SmartDetectObjectType.PERSON.value
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_SCORE] == 100
assert state.attributes[ATTR_EVENT_THUMB].startswith(
f"/api/ufp/thumbnail/test_event_id?entity_id={entity_id}&token="
)

View file

@ -0,0 +1,236 @@
"""Test UniFi Protect views."""
# pylint: disable=protected-access
from __future__ import annotations
from datetime import datetime, timedelta
from unittest.mock import AsyncMock
import pytest
from pyunifiprotect.data import Camera, Event, EventType
from pyunifiprotect.exceptions import NvrError
from homeassistant.components.unifiprotect.binary_sensor import MOTION_SENSORS
from homeassistant.components.unifiprotect.const import ATTR_EVENT_THUMB
from homeassistant.components.unifiprotect.entity import TOKEN_CHANGE_INTERVAL
from homeassistant.const import STATE_ON, Platform
from homeassistant.core import HomeAssistant
from .conftest import MockEntityFixture, ids_from_device_description, time_changed
@pytest.fixture(name="thumb_url")
async def thumb_url_fixture(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
mock_camera: Camera,
now: datetime,
):
"""Fixture for a single camera for testing the binary_sensor platform."""
# disable pydantic validation so mocking can happen
Camera.__config__.validate_assignment = False
camera_obj = mock_camera.copy(deep=True)
camera_obj._api = mock_entry.api
camera_obj.channels[0]._api = mock_entry.api
camera_obj.channels[1]._api = mock_entry.api
camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera"
camera_obj.is_motion_detected = True
event = Event(
id="test_event_id",
type=EventType.MOTION,
start=now - timedelta(seconds=1),
end=None,
score=100,
smart_detect_types=[],
smart_detect_event_ids=[],
camera_id=camera_obj.id,
)
camera_obj.last_motion_event_id = event.id
mock_entry.api.bootstrap.reset_objects()
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj,
}
mock_entry.api.bootstrap.events = {event.id: event}
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
_, entity_id = ids_from_device_description(
Platform.BINARY_SENSOR, camera_obj, MOTION_SENSORS[0]
)
# make sure access tokens are generated
await time_changed(hass, 1)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
assert state.attributes[ATTR_EVENT_THUMB].startswith(
f"/api/ufp/thumbnail/test_event_id?entity_id={entity_id}&token="
)
yield state.attributes[ATTR_EVENT_THUMB]
Camera.__config__.validate_assignment = True
async def test_thumbnail_view_good(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
client = await hass_client_no_auth()
response = await client.get(thumb_url)
assert response.status == 200
mock_entry.api.get_event_thumbnail.assert_called_once_with(
"test_event_id", width=None, height=None
)
async def test_thumbnail_view_good_args(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
client = await hass_client_no_auth()
response = await client.get(thumb_url + "&w=200&h=200")
assert response.status == 200
mock_entry.api.get_event_thumbnail.assert_called_once_with(
"test_event_id", width=200, height=200
)
async def test_thumbnail_view_bad_width(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
client = await hass_client_no_auth()
response = await client.get(thumb_url + "&w=safds&h=200")
assert response.status == 404
assert not mock_entry.api.get_event_thumbnail.called
async def test_thumbnail_view_bad_height(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
client = await hass_client_no_auth()
response = await client.get(thumb_url + "&w=200&h=asda")
assert response.status == 404
assert not mock_entry.api.get_event_thumbnail.called
async def test_thumbnail_view_bad_entity_id(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
client = await hass_client_no_auth()
response = await client.get("/api/ufp/thumbnail/test_event_id?entity_id=sdfsfd")
assert response.status == 404
assert not mock_entry.api.get_event_thumbnail.called
async def test_thumbnail_view_bad_access_token(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
client = await hass_client_no_auth()
thumb_url = thumb_url[:-1]
response = await client.get(thumb_url)
assert response.status == 401
assert not mock_entry.api.get_event_thumbnail.called
async def test_thumbnail_view_upstream_error(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock(side_effect=NvrError)
client = await hass_client_no_auth()
response = await client.get(thumb_url)
assert response.status == 404
async def test_thumbnail_view_no_thumb(
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock(return_value=None)
client = await hass_client_no_auth()
response = await client.get(thumb_url)
assert response.status == 404
async def test_thumbnail_view_expired_access_token(
hass: HomeAssistant,
thumb_url: str,
hass_client_no_auth,
mock_entry: MockEntityFixture,
):
"""Test good result from thumbnail view."""
mock_entry.api.get_event_thumbnail = AsyncMock()
await time_changed(hass, TOKEN_CHANGE_INTERVAL.total_seconds())
await time_changed(hass, TOKEN_CHANGE_INTERVAL.total_seconds())
client = await hass_client_no_auth()
response = await client.get(thumb_url)
assert response.status == 401