diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 49e88f866b6..1081fe3dbf1 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -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) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 36b8d9a8a01..373bcc2a0ab 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -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 key == _KEY_DOORBELL: - assert isinstance(self.device, Camera) - attrs[ATTR_LAST_TRIP_TIME] = self.device.last_ring + if isinstance(self.device, Camera): + if key == _KEY_DOORBELL: + 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 diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index ad81207dfc1..6b2fe8cbf84 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -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" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 0057af8db8c..f7f05b5bd4c 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -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] diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index cc142949ff7..5f42a523825 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -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(), + } diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index c69c0a4c950..d9f0f710a9b 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -6,6 +6,9 @@ "requirements": [ "pyunifiprotect==1.5.3" ], + "dependencies": [ + "http" + ], "codeowners": [ "@briis", "@AngellusMortis", diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index cfce716f0b4..6b1147c0bc7 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -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 diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py new file mode 100644 index 00000000000..4ec2f08ddca --- /dev/null +++ b/homeassistant/components/unifiprotect/views.py @@ -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") diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 7cd9a95dcb2..e5b00228e13 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -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={}, ) diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3414ec18848..6fc96d31359 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -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=" + ) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 9d901cad3a0..8c5f68f81fd 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -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=" + ) diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py new file mode 100644 index 00000000000..f42923acd1e --- /dev/null +++ b/tests/components/unifiprotect/test_views.py @@ -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