229 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			229 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Python
		
	
	
"""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 uiprotect.data import Camera, Event
 | 
						|
from uiprotect.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 .data import ProtectData, async_get_data_for_entry_id, async_get_data_for_nvr_id
 | 
						|
 | 
						|
_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}"
 | 
						|
    return 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(),
 | 
						|
    )
 | 
						|
 | 
						|
 | 
						|
@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
 | 
						|
 | 
						|
    def _get_data_or_404(self, nvr_id_or_entry_id: str) -> ProtectData | web.Response:
 | 
						|
        if data := (
 | 
						|
            async_get_data_for_nvr_id(self.hass, nvr_id_or_entry_id)
 | 
						|
            or async_get_data_for_entry_id(self.hass, nvr_id_or_entry_id)
 | 
						|
        ):
 | 
						|
            return 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
 |