mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +00:00
Add UniFi Protect camera motion sensors and ThumbnailProxyView (#63696)
This commit is contained in:
parent
71208b2ebb
commit
0232021f5c
@ -33,6 +33,7 @@ from .const import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .data import ProtectData
|
from .data import ProtectData
|
||||||
|
from .views import ThumbnailProxyView
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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.data.setdefault(DOMAIN, {})[entry.entry_id] = data_service
|
||||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
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(entry.add_update_listener(_async_options_updated))
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
|
||||||
|
@ -6,14 +6,10 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
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 (
|
from homeassistant.components.binary_sensor import (
|
||||||
DEVICE_CLASS_BATTERY,
|
BinarySensorDeviceClass,
|
||||||
DEVICE_CLASS_DOOR,
|
|
||||||
DEVICE_CLASS_MOTION,
|
|
||||||
DEVICE_CLASS_OCCUPANCY,
|
|
||||||
DEVICE_CLASS_PROBLEM,
|
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
BinarySensorEntityDescription,
|
BinarySensorEntityDescription,
|
||||||
)
|
)
|
||||||
@ -25,7 +21,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .data import ProtectData
|
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 .models import ProtectRequiredKeysMixin
|
||||||
from .utils import get_nested_attr
|
from .utils import get_nested_attr
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
|||||||
ProtectBinaryEntityDescription(
|
ProtectBinaryEntityDescription(
|
||||||
key=_KEY_DOORBELL,
|
key=_KEY_DOORBELL,
|
||||||
name="Doorbell",
|
name="Doorbell",
|
||||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||||
icon="mdi:doorbell-video",
|
icon="mdi:doorbell-video",
|
||||||
ufp_required_field="feature_flags.has_chime",
|
ufp_required_field="feature_flags.has_chime",
|
||||||
ufp_value="is_ringing",
|
ufp_value="is_ringing",
|
||||||
@ -74,7 +75,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
|||||||
ProtectBinaryEntityDescription(
|
ProtectBinaryEntityDescription(
|
||||||
key=_KEY_MOTION,
|
key=_KEY_MOTION,
|
||||||
name="Motion Detected",
|
name="Motion Detected",
|
||||||
device_class=DEVICE_CLASS_MOTION,
|
device_class=BinarySensorDeviceClass.MOTION,
|
||||||
ufp_value="is_pir_motion_detected",
|
ufp_value="is_pir_motion_detected",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -83,29 +84,39 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
|||||||
ProtectBinaryEntityDescription(
|
ProtectBinaryEntityDescription(
|
||||||
key=_KEY_DOOR,
|
key=_KEY_DOOR,
|
||||||
name="Door",
|
name="Door",
|
||||||
device_class=DEVICE_CLASS_DOOR,
|
device_class=BinarySensorDeviceClass.DOOR,
|
||||||
ufp_value="is_opened",
|
ufp_value="is_opened",
|
||||||
),
|
),
|
||||||
ProtectBinaryEntityDescription(
|
ProtectBinaryEntityDescription(
|
||||||
key=_KEY_BATTERY_LOW,
|
key=_KEY_BATTERY_LOW,
|
||||||
name="Battery low",
|
name="Battery low",
|
||||||
device_class=DEVICE_CLASS_BATTERY,
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
ufp_value="battery_status.is_low",
|
ufp_value="battery_status.is_low",
|
||||||
),
|
),
|
||||||
ProtectBinaryEntityDescription(
|
ProtectBinaryEntityDescription(
|
||||||
key=_KEY_MOTION,
|
key=_KEY_MOTION,
|
||||||
name="Motion Detected",
|
name="Motion Detected",
|
||||||
device_class=DEVICE_CLASS_MOTION,
|
device_class=BinarySensorDeviceClass.MOTION,
|
||||||
ufp_value="is_motion_detected",
|
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, ...] = (
|
DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
||||||
ProtectBinaryEntityDescription(
|
ProtectBinaryEntityDescription(
|
||||||
key=_KEY_DISK_HEALTH,
|
key=_KEY_DISK_HEALTH,
|
||||||
name="Disk {index} Health",
|
name="Disk {index} Health",
|
||||||
device_class=DEVICE_CLASS_PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -125,11 +136,29 @@ async def async_setup_entry(
|
|||||||
light_descs=LIGHT_SENSORS,
|
light_descs=LIGHT_SENSORS,
|
||||||
sense_descs=SENSE_SENSORS,
|
sense_descs=SENSE_SENSORS,
|
||||||
)
|
)
|
||||||
|
entities += _async_motion_entities(data)
|
||||||
entities += _async_nvr_entities(data)
|
entities += _async_nvr_entities(data)
|
||||||
|
|
||||||
async_add_entities(entities)
|
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
|
@callback
|
||||||
def _async_nvr_entities(
|
def _async_nvr_entities(
|
||||||
data: ProtectData,
|
data: ProtectData,
|
||||||
@ -173,9 +202,11 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity):
|
|||||||
if key == _KEY_DARK:
|
if key == _KEY_DARK:
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
if key == _KEY_DOORBELL:
|
if isinstance(self.device, Camera):
|
||||||
assert isinstance(self.device, Camera)
|
if key == _KEY_DOORBELL:
|
||||||
attrs[ATTR_LAST_TRIP_TIME] = self.device.last_ring
|
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):
|
elif isinstance(self.device, Sensor):
|
||||||
if key in (_KEY_MOTION, _KEY_DOOR):
|
if key in (_KEY_MOTION, _KEY_DOOR):
|
||||||
if key == _KEY_MOTION:
|
if key == _KEY_MOTION:
|
||||||
@ -199,9 +230,11 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity):
|
|||||||
self._attr_is_on = get_nested_attr(
|
self._attr_is_on = get_nested_attr(
|
||||||
self.device, self.entity_description.ufp_value
|
self.device, self.entity_description.ufp_value
|
||||||
)
|
)
|
||||||
self._attr_extra_state_attributes = (
|
attrs = self.extra_state_attributes or {}
|
||||||
self._async_update_extra_attrs_from_protect()
|
self._attr_extra_state_attributes = {
|
||||||
)
|
**attrs,
|
||||||
|
**self._async_update_extra_attrs_from_protect(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
|
class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
|
||||||
@ -233,3 +266,27 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
|
|||||||
disk = disks[self._index]
|
disk = disks[self._index]
|
||||||
self._attr_is_on = not disk.healthy
|
self._attr_is_on = not disk.healthy
|
||||||
self._attr_extra_state_attributes = {ATTR_MODEL: disk.model}
|
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"
|
DOMAIN = "unifiprotect"
|
||||||
|
|
||||||
|
ATTR_EVENT_SCORE = "event_score"
|
||||||
|
ATTR_EVENT_THUMB = "event_thumbnail"
|
||||||
ATTR_WIDTH = "width"
|
ATTR_WIDTH = "width"
|
||||||
ATTR_HEIGHT = "height"
|
ATTR_HEIGHT = "height"
|
||||||
ATTR_FPS = "fps"
|
ATTR_FPS = "fps"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"""Base class for protect data."""
|
"""Base class for protect data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
from collections import deque
|
||||||
from collections.abc import Generator, Iterable
|
from collections.abc import Generator, Iterable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
@ -43,7 +43,7 @@ class ProtectData:
|
|||||||
self._unsub_websocket: CALLBACK_TYPE | None = None
|
self._unsub_websocket: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
self.last_update_success = False
|
self.last_update_success = False
|
||||||
self.access_tokens: dict[str, collections.deque] = {}
|
self.access_tokens: dict[str, deque] = {}
|
||||||
self.api = protect
|
self.api = protect
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -177,3 +177,10 @@ class ProtectData:
|
|||||||
_LOGGER.debug("Updating device: %s", device_id)
|
_LOGGER.debug("Updating device: %s", device_id)
|
||||||
for update_callback in self._subscriptions[device_id]:
|
for update_callback in self._subscriptions[device_id]:
|
||||||
update_callback()
|
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."""
|
"""Shared Entity definition for UniFi Protect Integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from random import SystemRandom
|
||||||
|
from typing import Any, Final
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from pyunifiprotect.data import (
|
from pyunifiprotect.data import (
|
||||||
Camera,
|
Camera,
|
||||||
|
Event,
|
||||||
Light,
|
Light,
|
||||||
ModelType,
|
ModelType,
|
||||||
ProtectAdoptableDeviceModel,
|
ProtectAdoptableDeviceModel,
|
||||||
@ -19,11 +26,21 @@ from homeassistant.core import callback
|
|||||||
import homeassistant.helpers.device_registry as dr
|
import homeassistant.helpers.device_registry as dr
|
||||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
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 .data import ProtectData
|
||||||
from .models import ProtectRequiredKeysMixin
|
from .models import ProtectRequiredKeysMixin
|
||||||
from .utils import get_nested_attr
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -203,3 +220,99 @@ class ProtectNVREntity(ProtectDeviceEntity):
|
|||||||
self.device = self.data.api.bootstrap.nvr
|
self.device = self.data.api.bootstrap.nvr
|
||||||
|
|
||||||
self._attr_available = self.data.last_update_success
|
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": [
|
"requirements": [
|
||||||
"pyunifiprotect==1.5.3"
|
"pyunifiprotect==1.5.3"
|
||||||
],
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"http"
|
||||||
|
],
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@briis",
|
"@briis",
|
||||||
"@AngellusMortis",
|
"@AngellusMortis",
|
||||||
|
@ -6,10 +6,11 @@ from datetime import datetime, timedelta
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pyunifiprotect.data import NVR
|
from pyunifiprotect.data import NVR, Camera, Event
|
||||||
from pyunifiprotect.data.base import ProtectAdoptableDeviceModel
|
from pyunifiprotect.data.base import ProtectAdoptableDeviceModel
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
@ -19,13 +20,6 @@ from homeassistant.const import (
|
|||||||
DATA_BYTES,
|
DATA_BYTES,
|
||||||
DATA_RATE_BYTES_PER_SECOND,
|
DATA_RATE_BYTES_PER_SECOND,
|
||||||
DATA_RATE_MEGABITS_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,
|
ELECTRIC_POTENTIAL_VOLT,
|
||||||
LIGHT_LUX,
|
LIGHT_LUX,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
@ -39,11 +33,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .data import ProtectData
|
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 .models import ProtectRequiredKeysMixin
|
||||||
from .utils import get_nested_attr
|
from .utils import get_nested_attr
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
DETECTED_OBJECT_NONE = "none"
|
||||||
|
DEVICE_CLASS_DETECTION = "unifiprotect__detection"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -82,12 +83,14 @@ _KEY_RES_4K = "resolution_4K"
|
|||||||
_KEY_RES_FREE = "resolution_free"
|
_KEY_RES_FREE = "resolution_free"
|
||||||
_KEY_CAPACITY = "record_capacity"
|
_KEY_CAPACITY = "record_capacity"
|
||||||
|
|
||||||
|
_KEY_OBJECT = "detected_object"
|
||||||
|
|
||||||
ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
||||||
ProtectSensorEntityDescription(
|
ProtectSensorEntityDescription(
|
||||||
key=_KEY_UPTIME,
|
key=_KEY_UPTIME,
|
||||||
name="Uptime",
|
name="Uptime",
|
||||||
icon="mdi:clock",
|
icon="mdi:clock",
|
||||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
ufp_value="up_since",
|
ufp_value="up_since",
|
||||||
@ -96,7 +99,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_BLE,
|
key=_KEY_BLE,
|
||||||
name="Bluetooth Signal Strength",
|
name="Bluetooth Signal Strength",
|
||||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||||
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@ -117,7 +120,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_WIFI,
|
key=_KEY_WIFI,
|
||||||
name="WiFi Signal Strength",
|
name="WiFi Signal Strength",
|
||||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
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_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@ -130,7 +133,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
ProtectSensorEntityDescription(
|
ProtectSensorEntityDescription(
|
||||||
key=_KEY_OLDEST,
|
key=_KEY_OLDEST,
|
||||||
name="Oldest Recording",
|
name="Oldest Recording",
|
||||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
ufp_value="stats.video.recording_start",
|
ufp_value="stats.video.recording_start",
|
||||||
),
|
),
|
||||||
@ -154,7 +157,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
ProtectSensorEntityDescription(
|
ProtectSensorEntityDescription(
|
||||||
key=_KEY_VOLTAGE,
|
key=_KEY_VOLTAGE,
|
||||||
name="Voltage",
|
name="Voltage",
|
||||||
device_class=DEVICE_CLASS_VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
|
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@ -192,7 +195,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_BATTERY,
|
key=_KEY_BATTERY,
|
||||||
name="Battery Level",
|
name="Battery Level",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
device_class=DEVICE_CLASS_BATTERY,
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
ufp_value="battery_status.percentage",
|
ufp_value="battery_status.percentage",
|
||||||
@ -201,7 +204,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_LIGHT,
|
key=_KEY_LIGHT,
|
||||||
name="Light Level",
|
name="Light Level",
|
||||||
native_unit_of_measurement=LIGHT_LUX,
|
native_unit_of_measurement=LIGHT_LUX,
|
||||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
ufp_value="stats.light.value",
|
ufp_value="stats.light.value",
|
||||||
),
|
),
|
||||||
@ -209,7 +212,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_HUMIDITY,
|
key=_KEY_HUMIDITY,
|
||||||
name="Humidity Level",
|
name="Humidity Level",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
device_class=DEVICE_CLASS_HUMIDITY,
|
device_class=SensorDeviceClass.HUMIDITY,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
ufp_value="stats.humidity.value",
|
ufp_value="stats.humidity.value",
|
||||||
),
|
),
|
||||||
@ -217,7 +220,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_TEMP,
|
key=_KEY_TEMP,
|
||||||
name="Temperature",
|
name="Temperature",
|
||||||
native_unit_of_measurement=TEMP_CELSIUS,
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
ufp_value="stats.temperature.value",
|
ufp_value="stats.temperature.value",
|
||||||
),
|
),
|
||||||
@ -228,7 +231,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_UPTIME,
|
key=_KEY_UPTIME,
|
||||||
name="Uptime",
|
name="Uptime",
|
||||||
icon="mdi:clock",
|
icon="mdi:clock",
|
||||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
ufp_value="up_since",
|
ufp_value="up_since",
|
||||||
),
|
),
|
||||||
@ -328,7 +331,7 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
|||||||
key=_KEY_CPU_TEMP,
|
key=_KEY_CPU_TEMP,
|
||||||
name="CPU Temperature",
|
name="CPU Temperature",
|
||||||
native_unit_of_measurement=TEMP_CELSIUS,
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -361,11 +372,32 @@ async def async_setup_entry(
|
|||||||
camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS,
|
camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS,
|
||||||
sense_descs=SENSE_SENSORS,
|
sense_descs=SENSE_SENSORS,
|
||||||
)
|
)
|
||||||
|
entities += _async_motion_entities(data)
|
||||||
entities += _async_nvr_entities(data)
|
entities += _async_nvr_entities(data)
|
||||||
|
|
||||||
async_add_entities(entities)
|
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
|
@callback
|
||||||
def _async_nvr_entities(
|
def _async_nvr_entities(
|
||||||
data: ProtectData,
|
data: ProtectData,
|
||||||
@ -415,7 +447,8 @@ class ProtectDeviceSensor(SensorValueMixin, ProtectDeviceEntity, SensorEntity):
|
|||||||
def _async_update_device_from_protect(self) -> None:
|
def _async_update_device_from_protect(self) -> None:
|
||||||
super()._async_update_device_from_protect()
|
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)
|
value = get_nested_attr(self.device, self.entity_description.ufp_value)
|
||||||
self._attr_native_value = self._clean_sensor_value(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)
|
value = get_nested_attr(self.device, self.entity_description.ufp_value)
|
||||||
|
|
||||||
self._attr_native_value = self._clean_sensor_value(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]
|
sensors: dict[str, Any]
|
||||||
viewers: dict[str, Any]
|
viewers: dict[str, Any]
|
||||||
liveviews: dict[str, Any]
|
liveviews: dict[str, Any]
|
||||||
|
events: dict[str, Any]
|
||||||
|
|
||||||
def reset_objects(self) -> None:
|
def reset_objects(self) -> None:
|
||||||
"""Reset all devices on bootstrap for tests."""
|
"""Reset all devices on bootstrap for tests."""
|
||||||
@ -45,6 +46,7 @@ class MockBootstrap:
|
|||||||
self.sensors = {}
|
self.sensors = {}
|
||||||
self.viewers = {}
|
self.viewers = {}
|
||||||
self.liveviews = {}
|
self.liveviews = {}
|
||||||
|
self.events = {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -83,7 +85,13 @@ def mock_old_nvr_fixture():
|
|||||||
def mock_bootstrap_fixture(mock_nvr: NVR):
|
def mock_bootstrap_fixture(mock_nvr: NVR):
|
||||||
"""Mock Bootstrap fixture."""
|
"""Mock Bootstrap fixture."""
|
||||||
return MockBootstrap(
|
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
|
# pylint: disable=protected-access
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import copy
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pyunifiprotect.data import Camera, Light
|
from pyunifiprotect.data import Camera, Event, EventType, Light, Sensor
|
||||||
from pyunifiprotect.data.devices import Sensor
|
|
||||||
|
|
||||||
from homeassistant.components.unifiprotect.binary_sensor import (
|
from homeassistant.components.unifiprotect.binary_sensor import (
|
||||||
CAMERA_SENSORS,
|
CAMERA_SENSORS,
|
||||||
LIGHT_SENSORS,
|
LIGHT_SENSORS,
|
||||||
|
MOTION_SENSORS,
|
||||||
SENSE_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 (
|
from homeassistant.const import (
|
||||||
ATTR_ATTRIBUTION,
|
ATTR_ATTRIBUTION,
|
||||||
ATTR_LAST_TRIP_TIME,
|
ATTR_LAST_TRIP_TIME,
|
||||||
STATE_OFF,
|
STATE_OFF,
|
||||||
|
STATE_ON,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -51,6 +58,7 @@ async def camera_fixture(
|
|||||||
camera_obj.feature_flags.has_chime = True
|
camera_obj.feature_flags.has_chime = True
|
||||||
camera_obj.last_ring = now - timedelta(hours=1)
|
camera_obj.last_ring = now - timedelta(hours=1)
|
||||||
camera_obj.is_dark = False
|
camera_obj.is_dark = False
|
||||||
|
camera_obj.is_motion_detected = False
|
||||||
|
|
||||||
mock_entry.api.bootstrap.reset_objects()
|
mock_entry.api.bootstrap.reset_objects()
|
||||||
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
|
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.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
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
|
yield camera_obj
|
||||||
|
|
||||||
@ -117,6 +125,7 @@ async def camera_none_fixture(
|
|||||||
camera_obj.name = "Test Camera"
|
camera_obj.name = "Test Camera"
|
||||||
camera_obj.feature_flags.has_chime = False
|
camera_obj.feature_flags.has_chime = False
|
||||||
camera_obj.is_dark = False
|
camera_obj.is_dark = False
|
||||||
|
camera_obj.is_motion_detected = False
|
||||||
|
|
||||||
mock_entry.api.bootstrap.reset_objects()
|
mock_entry.api.bootstrap.reset_objects()
|
||||||
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
|
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.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
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
|
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)
|
assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1)
|
||||||
|
|
||||||
|
# Is Dark
|
||||||
description = CAMERA_SENSORS[1]
|
description = CAMERA_SENSORS[1]
|
||||||
unique_id, entity_id = ids_from_device_description(
|
unique_id, entity_id = ids_from_device_description(
|
||||||
Platform.BINARY_SENSOR, camera, 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.state == STATE_OFF
|
||||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
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(
|
async def test_binary_sensor_setup_camera_none(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -281,3 +308,48 @@ async def test_binary_sensor_setup_sensor(
|
|||||||
|
|
||||||
if index != 1:
|
if index != 1:
|
||||||
assert state.attributes[ATTR_LAST_TRIP_TIME] == expected_trip_time
|
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
|
# pylint: disable=protected-access
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from copy import copy
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pyunifiprotect.data import NVR, Camera, Event, Sensor
|
||||||
from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState
|
from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState
|
||||||
from pyunifiprotect.data.devices import Camera, Sensor
|
from pyunifiprotect.data.types import EventType, SmartDetectObjectType
|
||||||
from pyunifiprotect.data.nvr import NVR
|
|
||||||
|
|
||||||
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 (
|
from homeassistant.components.unifiprotect.sensor import (
|
||||||
ALL_DEVICES_SENSORS,
|
ALL_DEVICES_SENSORS,
|
||||||
CAMERA_DISABLED_SENSORS,
|
CAMERA_DISABLED_SENSORS,
|
||||||
CAMERA_SENSORS,
|
CAMERA_SENSORS,
|
||||||
|
DETECTED_OBJECT_NONE,
|
||||||
|
MOTION_SENSORS,
|
||||||
NVR_DISABLED_SENSORS,
|
NVR_DISABLED_SENSORS,
|
||||||
NVR_SENSORS,
|
NVR_SENSORS,
|
||||||
SENSE_SENSORS,
|
SENSE_SENSORS,
|
||||||
@ -86,6 +94,8 @@ async def camera_fixture(
|
|||||||
camera_obj.channels[1]._api = mock_entry.api
|
camera_obj.channels[1]._api = mock_entry.api
|
||||||
camera_obj.channels[2]._api = mock_entry.api
|
camera_obj.channels[2]._api = mock_entry.api
|
||||||
camera_obj.name = "Test Camera"
|
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.wired_connection_state = WiredConnectionState(phy_rate=1000)
|
||||||
camera_obj.wifi_connection_state = WifiConnectionState(
|
camera_obj.wifi_connection_state = WifiConnectionState(
|
||||||
signal_quality=100, signal_strength=-50
|
signal_quality=100, signal_strength=-50
|
||||||
@ -108,7 +118,7 @@ async def camera_fixture(
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
# 3 from all, 6 from camera, 12 NVR
|
# 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
|
yield camera_obj
|
||||||
|
|
||||||
@ -347,3 +357,64 @@ async def test_sensor_setup_camera(
|
|||||||
assert state
|
assert state
|
||||||
assert state.state == "-50"
|
assert state.state == "-50"
|
||||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
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…
x
Reference in New Issue
Block a user