"""UniFi Protect Integration views.""" from __future__ import annotations from datetime import datetime from http import HTTPStatus import logging from typing import Any from urllib.parse import urlencode from aiohttp import web from pyunifiprotect.data import Camera, Event from pyunifiprotect.exceptions import ClientError from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN from .data import ProtectData _LOGGER = logging.getLogger(__name__) @callback def async_generate_thumbnail_url( event_id: str, nvr_id: str, width: int | None = None, height: int | None = None, ) -> str: """Generate URL for event thumbnail.""" url_format = ThumbnailProxyView.url or "{nvr_id}/{event_id}" url = url_format.format(nvr_id=nvr_id, event_id=event_id) params = {} if width is not None: params["width"] = str(width) if height is not None: params["height"] = str(height) return f"{url}?{urlencode(params)}" @callback def async_generate_event_video_url(event: Event) -> str: """Generate URL for event video.""" _validate_event(event) if event.start is None or event.end is None: raise ValueError("Event is ongoing") url_format = VideoProxyView.url or "{nvr_id}/{camera_id}/{start}/{end}" url = url_format.format( nvr_id=event.api.bootstrap.nvr.id, camera_id=event.camera_id, start=event.start.replace(microsecond=0).isoformat(), end=event.end.replace(microsecond=0).isoformat(), ) return url @callback def _client_error(message: Any, code: HTTPStatus) -> web.Response: _LOGGER.warning("Client error (%s): %s", code.value, message) if code == HTTPStatus.BAD_REQUEST: return web.Response(body=message, status=code) return web.Response(status=code) @callback def _400(message: Any) -> web.Response: return _client_error(message, HTTPStatus.BAD_REQUEST) @callback def _403(message: Any) -> web.Response: return _client_error(message, HTTPStatus.FORBIDDEN) @callback def _404(message: Any) -> web.Response: return _client_error(message, HTTPStatus.NOT_FOUND) @callback def _validate_event(event: Event) -> None: if event.camera is None: raise ValueError("Event does not have a camera") if not event.camera.can_read_media(event.api.bootstrap.auth_user): raise PermissionError(f"User cannot read media from camera: {event.camera.id}") class ProtectProxyView(HomeAssistantView): """Base class to proxy request to UniFi Protect console.""" requires_auth = True def __init__(self, hass: HomeAssistant) -> None: """Initialize a thumbnail proxy view.""" self.hass = hass self.data = hass.data[DOMAIN] def _get_data_or_404(self, nvr_id: str) -> ProtectData | web.Response: all_data: list[ProtectData] = [] for entry_id, data in self.data.items(): if isinstance(data, ProtectData): if nvr_id == entry_id: return data if data.api.bootstrap.nvr.id == nvr_id: return data all_data.append(data) return _404("Invalid NVR ID") class ThumbnailProxyView(ProtectProxyView): """View to proxy event thumbnails from UniFi Protect.""" url = "/api/unifiprotect/thumbnail/{nvr_id}/{event_id}" name = "api:unifiprotect_thumbnail" async def get( self, request: web.Request, nvr_id: str, event_id: str ) -> web.Response: """Get Event Thumbnail.""" data = self._get_data_or_404(nvr_id) if isinstance(data, web.Response): return data width: int | str | None = request.query.get("width") height: int | str | None = request.query.get("height") if width is not None: try: width = int(width) except ValueError: return _400("Invalid width param") if height is not None: try: height = int(height) except ValueError: return _400("Invalid height param") try: thumbnail = await data.api.get_event_thumbnail( event_id, width=width, height=height ) except ClientError as err: return _404(err) if thumbnail is None: return _404("Event thumbnail not found") return web.Response(body=thumbnail, content_type="image/jpeg") class VideoProxyView(ProtectProxyView): """View to proxy video clips from UniFi Protect.""" url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}" name = "api:unifiprotect_thumbnail" @callback def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None: if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None: return camera entity_registry = er.async_get(self.hass) device_registry = dr.async_get(self.hass) if (entity := entity_registry.async_get(camera_id)) is None or ( device := device_registry.async_get(entity.device_id or "") ) is None: return None macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC] for mac in macs: if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None: if isinstance(ufp_device, Camera): camera = ufp_device break return camera async def get( self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str ) -> web.StreamResponse: """Get Camera Video clip.""" data = self._get_data_or_404(nvr_id) if isinstance(data, web.Response): return data camera = self._async_get_camera(data, camera_id) if camera is None: return _404(f"Invalid camera ID: {camera_id}") if not camera.can_read_media(data.api.bootstrap.auth_user): return _403(f"User cannot read media from camera: {camera.id}") try: start_dt = datetime.fromisoformat(start) except ValueError: return _400("Invalid start") try: end_dt = datetime.fromisoformat(end) except ValueError: return _400("Invalid end") response = web.StreamResponse( status=200, reason="OK", headers={ "Content-Type": "video/mp4", }, ) async def iterator(total: int, chunk: bytes | None) -> None: if not response.prepared: response.content_length = total await response.prepare(request) if chunk is not None: await response.write(chunk) try: await camera.get_video(start_dt, end_dt, iterator_callback=iterator) except ClientError as err: return _404(err) if response.prepared: await response.write_eof() return response