mirror of https://github.com/home-assistant/core
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
|