Add UniFi Protect camera motion sensors and ThumbnailProxyView (#63696)
This commit is contained in:
parent
71208b2ebb
commit
0232021f5c
12 changed files with 787 additions and 54 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
"requirements": [
|
||||
"pyunifiprotect==1.5.3"
|
||||
],
|
||||
"dependencies": [
|
||||
"http"
|
||||
],
|
||||
"codeowners": [
|
||||
"@briis",
|
||||
"@AngellusMortis",
|
||||
|
|
|
@ -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
|
||||
|
|
91
homeassistant/components/unifiprotect/views.py
Normal file
91
homeassistant/components/unifiprotect/views.py
Normal 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")
|
|
@ -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={},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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="
|
||||
)
|
||||
|
|
|
@ -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="
|
||||
)
|
||||
|
|
236
tests/components/unifiprotect/test_views.py
Normal file
236
tests/components/unifiprotect/test_views.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue