Home Assistant Unofficial Reference 2024.12.1
views.py
Go to the documentation of this file.
1 """UniFi Protect Integration views."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime
6 from http import HTTPStatus
7 import logging
8 from typing import TYPE_CHECKING, Any
9 from urllib.parse import urlencode
10 
11 from aiohttp import web
12 from uiprotect.data import Camera, Event
13 from uiprotect.exceptions import ClientError
14 
15 from homeassistant.components.http import HomeAssistantView
16 from homeassistant.core import HomeAssistant, callback
17 from homeassistant.helpers import device_registry as dr, entity_registry as er
18 
19 from .data import ProtectData, async_get_data_for_entry_id, async_get_data_for_nvr_id
20 
21 _LOGGER = logging.getLogger(__name__)
22 
23 
24 @callback
26  event_id: str,
27  nvr_id: str,
28  width: int | None = None,
29  height: int | None = None,
30 ) -> str:
31  """Generate URL for event thumbnail."""
32 
33  url_format = ThumbnailProxyView.url
34  if TYPE_CHECKING:
35  assert url_format is not None
36  url = url_format.format(nvr_id=nvr_id, event_id=event_id)
37 
38  params = {}
39  if width is not None:
40  params["width"] = str(width)
41  if height is not None:
42  params["height"] = str(height)
43 
44  return f"{url}?{urlencode(params)}"
45 
46 
47 @callback
48 def async_generate_event_video_url(event: Event) -> str:
49  """Generate URL for event video."""
50 
51  _validate_event(event)
52  if event.start is None or event.end is None:
53  raise ValueError("Event is ongoing")
54 
55  url_format = VideoProxyView.url
56  if TYPE_CHECKING:
57  assert url_format is not None
58  return url_format.format(
59  nvr_id=event.api.bootstrap.nvr.id,
60  camera_id=event.camera_id,
61  start=event.start.replace(microsecond=0).isoformat(),
62  end=event.end.replace(microsecond=0).isoformat(),
63  )
64 
65 
66 @callback
68  nvr_id: str,
69  event_id: str,
70 ) -> str:
71  """Generate proxy URL for event video."""
72 
73  url_format = VideoEventProxyView.url
74  if TYPE_CHECKING:
75  assert url_format is not None
76  return url_format.format(nvr_id=nvr_id, event_id=event_id)
77 
78 
79 @callback
80 def _client_error(message: Any, code: HTTPStatus) -> web.Response:
81  _LOGGER.warning("Client error (%s): %s", code.value, message)
82  if code == HTTPStatus.BAD_REQUEST:
83  return web.Response(body=message, status=code)
84  return web.Response(status=code)
85 
86 
87 @callback
88 def _400(message: Any) -> web.Response:
89  return _client_error(message, HTTPStatus.BAD_REQUEST)
90 
91 
92 @callback
93 def _403(message: Any) -> web.Response:
94  return _client_error(message, HTTPStatus.FORBIDDEN)
95 
96 
97 @callback
98 def _404(message: Any) -> web.Response:
99  return _client_error(message, HTTPStatus.NOT_FOUND)
100 
101 
102 @callback
103 def _validate_event(event: Event) -> None:
104  if event.camera is None:
105  raise ValueError("Event does not have a camera")
106  if not event.camera.can_read_media(event.api.bootstrap.auth_user):
107  raise PermissionError(f"User cannot read media from camera: {event.camera.id}")
108 
109 
110 class ProtectProxyView(HomeAssistantView):
111  """Base class to proxy request to UniFi Protect console."""
112 
113  requires_auth = True
114 
115  def __init__(self, hass: HomeAssistant) -> None:
116  """Initialize a thumbnail proxy view."""
117  self.hasshass = hass
118 
119  def _get_data_or_404(self, nvr_id_or_entry_id: str) -> ProtectData | web.Response:
120  if data := (
121  async_get_data_for_nvr_id(self.hasshass, nvr_id_or_entry_id)
122  or async_get_data_for_entry_id(self.hasshass, nvr_id_or_entry_id)
123  ):
124  return data
125  return _404("Invalid NVR ID")
126 
127  @callback
128  def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
129  if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
130  return camera
131 
132  entity_registry = er.async_get(self.hasshass)
133  device_registry = dr.async_get(self.hasshass)
134 
135  if (entity := entity_registry.async_get(camera_id)) is None or (
136  device := device_registry.async_get(entity.device_id or "")
137  ) is None:
138  return None
139 
140  macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
141  for mac in macs:
142  if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
143  if isinstance(ufp_device, Camera):
144  camera = ufp_device
145  break
146  return camera
147 
148 
150  """View to proxy event thumbnails from UniFi Protect."""
151 
152  url = "/api/unifiprotect/thumbnail/{nvr_id}/{event_id}"
153  name = "api:unifiprotect_thumbnail"
154 
155  async def get(
156  self, request: web.Request, nvr_id: str, event_id: str
157  ) -> web.Response:
158  """Get Event Thumbnail."""
159 
160  data = self._get_data_or_404_get_data_or_404(nvr_id)
161  if isinstance(data, web.Response):
162  return data
163 
164  width: int | str | None = request.query.get("width")
165  height: int | str | None = request.query.get("height")
166 
167  if width is not None:
168  try:
169  width = int(width)
170  except ValueError:
171  return _400("Invalid width param")
172  if height is not None:
173  try:
174  height = int(height)
175  except ValueError:
176  return _400("Invalid height param")
177 
178  try:
179  thumbnail = await data.api.get_event_thumbnail(
180  event_id, width=width, height=height
181  )
182  except ClientError as err:
183  return _404(err)
184 
185  if thumbnail is None:
186  return _404("Event thumbnail not found")
187 
188  return web.Response(body=thumbnail, content_type="image/jpeg")
189 
190 
192  """View to proxy video clips from UniFi Protect."""
193 
194  url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}"
195  name = "api:unifiprotect_thumbnail"
196 
197  async def get(
198  self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str
199  ) -> web.StreamResponse:
200  """Get Camera Video clip."""
201 
202  data = self._get_data_or_404_get_data_or_404(nvr_id)
203  if isinstance(data, web.Response):
204  return data
205 
206  camera = self._async_get_camera_async_get_camera(data, camera_id)
207  if camera is None:
208  return _404(f"Invalid camera ID: {camera_id}")
209  if not camera.can_read_media(data.api.bootstrap.auth_user):
210  return _403(f"User cannot read media from camera: {camera.id}")
211 
212  try:
213  start_dt = datetime.fromisoformat(start)
214  except ValueError:
215  return _400("Invalid start")
216 
217  try:
218  end_dt = datetime.fromisoformat(end)
219  except ValueError:
220  return _400("Invalid end")
221 
222  response = web.StreamResponse(
223  status=200,
224  reason="OK",
225  headers={
226  "Content-Type": "video/mp4",
227  },
228  )
229 
230  async def iterator(total: int, chunk: bytes | None) -> None:
231  if not response.prepared:
232  response.content_length = total
233  await response.prepare(request)
234 
235  if chunk is not None:
236  await response.write(chunk)
237 
238  try:
239  await camera.get_video(start_dt, end_dt, iterator_callback=iterator)
240  except ClientError as err:
241  return _404(err)
242 
243  if response.prepared:
244  await response.write_eof()
245  return response
246 
247 
249  """View to proxy video clips for events from UniFi Protect."""
250 
251  url = "/api/unifiprotect/video/{nvr_id}/{event_id}"
252  name = "api:unifiprotect_videoEventView"
253 
254  async def get(
255  self, request: web.Request, nvr_id: str, event_id: str
256  ) -> web.StreamResponse:
257  """Get Camera Video clip for an event."""
258 
259  data = self._get_data_or_404_get_data_or_404(nvr_id)
260  if isinstance(data, web.Response):
261  return data
262 
263  try:
264  event = await data.api.get_event(event_id)
265  except ClientError:
266  return _404(f"Invalid event ID: {event_id}")
267  if event.start is None or event.end is None:
268  return _400("Event is still ongoing")
269  camera = self._async_get_camera_async_get_camera(data, str(event.camera_id))
270  if camera is None:
271  return _404(f"Invalid camera ID: {event.camera_id}")
272  if not camera.can_read_media(data.api.bootstrap.auth_user):
273  return _403(f"User cannot read media from camera: {camera.id}")
274 
275  response = web.StreamResponse(
276  status=200,
277  reason="OK",
278  headers={
279  "Content-Type": "video/mp4",
280  },
281  )
282 
283  async def iterator(total: int, chunk: bytes | None) -> None:
284  if not response.prepared:
285  response.content_length = total
286  await response.prepare(request)
287 
288  if chunk is not None:
289  await response.write(chunk)
290 
291  try:
292  await camera.get_video(event.start, event.end, iterator_callback=iterator)
293  except ClientError as err:
294  return _404(err)
295 
296  if response.prepared:
297  await response.write_eof()
298  return response
Camera|None _async_get_camera(self, ProtectData data, str camera_id)
Definition: views.py:128
ProtectData|web.Response _get_data_or_404(self, str nvr_id_or_entry_id)
Definition: views.py:119
web.Response get(self, web.Request request, str nvr_id, str event_id)
Definition: views.py:157
web.StreamResponse get(self, web.Request request, str nvr_id, str event_id)
Definition: views.py:256
web.StreamResponse get(self, web.Request request, str nvr_id, str camera_id, str start, str end)
Definition: views.py:199
ProtectData|None async_get_data_for_entry_id(HomeAssistant hass, str entry_id)
Definition: data.py:390
ProtectData|None async_get_data_for_nvr_id(HomeAssistant hass, str nvr_id)
Definition: data.py:375
str async_generate_event_video_url(Event event)
Definition: views.py:48
str async_generate_thumbnail_url(str event_id, str nvr_id, int|None width=None, int|None height=None)
Definition: views.py:30
web.Response _400(Any message)
Definition: views.py:88
str async_generate_proxy_event_video_url(str nvr_id, str event_id)
Definition: views.py:70
web.Response _404(Any message)
Definition: views.py:98
web.Response _client_error(Any message, HTTPStatus code)
Definition: views.py:80
web.Response _403(Any message)
Definition: views.py:93