uiprotect 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of uiprotect might be problematic. Click here for more details.

uiprotect/api.py ADDED
@@ -0,0 +1,1936 @@
1
+ """UniFi Protect Server Wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hashlib
8
+ import logging
9
+ import re
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable
13
+ from datetime import datetime, timedelta
14
+ from http.cookies import Morsel, SimpleCookie
15
+ from ipaddress import IPv4Address, IPv6Address
16
+ from pathlib import Path
17
+ from typing import Any, Literal, cast
18
+ from urllib.parse import urljoin
19
+ from uuid import UUID
20
+
21
+ import aiofiles
22
+ import aiohttp
23
+ import orjson
24
+ from aiofiles import os as aos
25
+ from aiohttp import CookieJar, client_exceptions
26
+ from platformdirs import user_cache_dir, user_config_dir
27
+ from yarl import URL
28
+
29
+ from uiprotect.data import (
30
+ NVR,
31
+ Bootstrap,
32
+ Bridge,
33
+ Camera,
34
+ Doorlock,
35
+ Event,
36
+ EventCategories,
37
+ EventType,
38
+ Light,
39
+ Liveview,
40
+ ModelType,
41
+ ProtectAdoptableDeviceModel,
42
+ ProtectModel,
43
+ PTZPosition,
44
+ PTZPreset,
45
+ Sensor,
46
+ SmartDetectObjectType,
47
+ SmartDetectTrack,
48
+ Version,
49
+ Viewer,
50
+ WSPacket,
51
+ WSSubscriptionMessage,
52
+ create_from_unifi_dict,
53
+ )
54
+ from uiprotect.data.base import ProtectModelWithId
55
+ from uiprotect.data.devices import Chime
56
+ from uiprotect.data.types import IteratorCallback, ProgressCallback, RecordingMode
57
+ from uiprotect.exceptions import BadRequest, NotAuthorized, NvrError
58
+ from uiprotect.utils import (
59
+ decode_token_cookie,
60
+ get_response_reason,
61
+ ip_from_host,
62
+ set_debug,
63
+ to_js_time,
64
+ utc_now,
65
+ )
66
+ from uiprotect.websocket import Websocket
67
+
68
+ TOKEN_COOKIE_MAX_EXP_SECONDS = 60
69
+
70
+ NEVER_RAN = -1000
71
+ # how many seconds before the bootstrap is refreshed from Protect
72
+ DEVICE_UPDATE_INTERVAL = 900
73
+ # retry timeout for thumbnails/heatmaps
74
+ RETRY_TIMEOUT = 10
75
+ PROTECT_APT_URLS = [
76
+ "https://apt.artifacts.ui.com/dists/stretch/release/binary-arm64/Packages",
77
+ "https://apt.artifacts.ui.com/dists/bullseye/release/binary-arm64/Packages",
78
+ ]
79
+ TYPES_BUG_MESSAGE = """There is currently a bug in UniFi Protect that makes `start` / `end` not work if `types` is not provided. This means uiprotect has to iterate over all of the events matching the filters provided to return values.
80
+
81
+ If your Protect instance has a lot of events, this request will take much longer then expected. It is recommended adding additional filters to speed the request up."""
82
+
83
+
84
+ _LOGGER = logging.getLogger(__name__)
85
+ _COOKIE_RE = re.compile(r"^set-cookie: ", re.IGNORECASE)
86
+
87
+ # TODO: Urls to still support
88
+ # Backups
89
+ # * GET /backups - list backends
90
+ # * POST /backups/import - import backup
91
+ # * POST /backups - create backup
92
+ # * GET /backups/{id} - download backup
93
+ # * POST /backups/{id}/restore - restore backup
94
+ # * DELETE /backups/{id} - delete backup
95
+ #
96
+ # Cameras
97
+ # * POST /cameras/{id}/reset - factory reset camera
98
+ # * POST /cameras/{id}/reset-isp - reset ISP settings
99
+ # * POST /cameras/{id}/reset-isp - reset ISP settings
100
+ # * POST /cameras/{id}/wake - battery powered cameras
101
+ # * POST /cameras/{id}/sleep
102
+ # * POST /cameras/{id}/homekit-talkback-speaker-muted
103
+ # * GET /cameras/{id}/live-heatmap - add live heatmap to WebRTC stream
104
+ # * GET /cameras/{id}/enable-control - PTZ controls
105
+ # * GET /cameras/{id}/disable-control
106
+ # * POST /cameras/{id}/move
107
+ # * POST /cameras/{id}/ptz/position
108
+ # * GET|POST /cameras/{id}/ptz/preset
109
+ # * GET /cameras/{id}/ptz/snapshot
110
+ # * POST /cameras/{id}/ptz/goto
111
+ # * GET /cameras/{id}/analytics-heatmap - analytics
112
+ # * GET /cameras/{id}/analytics-detections
113
+ # * GET /cameras/{id}/wifi-list - WiFi scan
114
+ # * POST /cameras/{id}/wifi-setup - Change WiFi settings
115
+ # * GET /cameras/{id}/playback-history
116
+ # * GET|POST|DELETE /cameras/{id}/sharedStream - stream sharing, unfinished?
117
+ #
118
+ # Device Groups
119
+ # * GET|POST|PUT|DELETE /device-groups
120
+ # * GET|PATCH|DELETE /device-groups/{id}
121
+ # * PATCH /device-groups/{id}/items
122
+ #
123
+ # Events
124
+ # POST /events/{id}/animated-thumbnail
125
+ #
126
+ # Lights
127
+ # POST /lights/{id}/locate
128
+ #
129
+ # NVR
130
+ # GET|PATCH /nvr/device-password
131
+ #
132
+ # Schedules
133
+ # GET|POST /recordingSchedules
134
+ # PATCH|DELETE /recordingSchedules/{id}
135
+ #
136
+ # Sensors
137
+ # POST /sensors/{id}/locate
138
+ #
139
+ # Timeline
140
+ # GET /timeline
141
+
142
+
143
+ def get_user_hash(host: str, username: str) -> str:
144
+ session = hashlib.sha256()
145
+ session.update(host.encode("utf8"))
146
+ session.update(username.encode("utf8"))
147
+ return session.hexdigest()
148
+
149
+
150
+ class BaseApiClient:
151
+ _host: str
152
+ _port: int
153
+ _username: str
154
+ _password: str
155
+ _verify_ssl: bool
156
+ _ws_timeout: int
157
+
158
+ _is_authenticated: bool = False
159
+ _last_update: float = NEVER_RAN
160
+ _last_ws_status: bool = False
161
+ _last_token_cookie: Morsel[str] | None = None
162
+ _last_token_cookie_decode: dict[str, Any] | None = None
163
+ _session: aiohttp.ClientSession | None = None
164
+ _loaded_session: bool = False
165
+
166
+ headers: dict[str, str] | None = None
167
+ _websocket: Websocket | None = None
168
+
169
+ api_path: str = "/proxy/protect/api/"
170
+ ws_path: str = "/proxy/protect/ws/updates"
171
+
172
+ cache_dir: Path
173
+ config_dir: Path
174
+ store_sessions: bool
175
+
176
+ def __init__(
177
+ self,
178
+ host: str,
179
+ port: int,
180
+ username: str,
181
+ password: str,
182
+ verify_ssl: bool = True,
183
+ session: aiohttp.ClientSession | None = None,
184
+ ws_timeout: int = 30,
185
+ cache_dir: Path | None = None,
186
+ config_dir: Path | None = None,
187
+ store_sessions: bool = True,
188
+ ) -> None:
189
+ self._auth_lock = asyncio.Lock()
190
+ self._host = host
191
+ self._port = port
192
+
193
+ self._username = username
194
+ self._password = password
195
+ self._verify_ssl = verify_ssl
196
+ self._ws_timeout = ws_timeout
197
+ self._loaded_session = False
198
+
199
+ self.config_dir = config_dir or (Path(user_config_dir()) / "ufp")
200
+ self.cache_dir = cache_dir or (Path(user_cache_dir()) / "ufp_cache")
201
+ self.store_sessions = store_sessions
202
+
203
+ if session is not None:
204
+ self._session = session
205
+
206
+ self._update_url()
207
+
208
+ def _update_url(self) -> None:
209
+ """Updates the url after changing _host or _port."""
210
+ if self._port != 443:
211
+ self._url = URL(f"https://{self._host}:{self._port}")
212
+ else:
213
+ self._url = URL(f"https://{self._host}")
214
+
215
+ self.base_url = str(self._url)
216
+
217
+ @property
218
+ def ws_url(self) -> str:
219
+ url = f"wss://{self._host}"
220
+ if self._port != 443:
221
+ url += f":{self._port}"
222
+
223
+ url += self.ws_path
224
+ last_update_id = self._get_last_update_id()
225
+ if last_update_id is None:
226
+ return url
227
+ return f"{url}?lastUpdateId={last_update_id}"
228
+
229
+ @property
230
+ def config_file(self) -> Path:
231
+ return self.config_dir / "unifi_protect.json"
232
+
233
+ async def get_session(self) -> aiohttp.ClientSession:
234
+ """Gets or creates current client session"""
235
+ if self._session is None or self._session.closed:
236
+ if self._session is not None and self._session.closed:
237
+ _LOGGER.debug("Session was closed, creating a new one")
238
+ # need unsafe to access httponly cookies
239
+ self._session = aiohttp.ClientSession(cookie_jar=CookieJar(unsafe=True))
240
+
241
+ return self._session
242
+
243
+ async def get_websocket(self) -> Websocket:
244
+ """Gets or creates current Websocket."""
245
+
246
+ async def _auth(force: bool) -> dict[str, str] | None:
247
+ if force:
248
+ if self._session is not None:
249
+ self._session.cookie_jar.clear()
250
+ self.set_header("cookie", None)
251
+ self.set_header("x-csrf-token", None)
252
+
253
+ await self.ensure_authenticated()
254
+ return self.headers
255
+
256
+ if self._websocket is None:
257
+ self._websocket = Websocket(
258
+ self.ws_url,
259
+ _auth,
260
+ verify=self._verify_ssl,
261
+ timeout=self._ws_timeout,
262
+ )
263
+ self._websocket.subscribe(self._process_ws_message)
264
+
265
+ return self._websocket
266
+
267
+ async def close_session(self) -> None:
268
+ """Closing and delets client session"""
269
+ if self._session is not None:
270
+ await self._session.close()
271
+ self._session = None
272
+ self._loaded_session = False
273
+
274
+ def set_header(self, key: str, value: str | None) -> None:
275
+ """Set header."""
276
+ self.headers = self.headers or {}
277
+ if value is None:
278
+ self.headers.pop(key, None)
279
+ else:
280
+ self.headers[key] = value
281
+
282
+ async def request(
283
+ self,
284
+ method: str,
285
+ url: str,
286
+ require_auth: bool = False,
287
+ auto_close: bool = True,
288
+ **kwargs: Any,
289
+ ) -> aiohttp.ClientResponse:
290
+ """Make a request to UniFi Protect"""
291
+ if require_auth:
292
+ await self.ensure_authenticated()
293
+
294
+ request_url = self._url.joinpath(url[1:])
295
+ headers = kwargs.get("headers") or self.headers
296
+ _LOGGER.debug("Request url: %s", request_url)
297
+ if not self._verify_ssl:
298
+ kwargs["ssl"] = False
299
+ session = await self.get_session()
300
+
301
+ for attempt in range(2):
302
+ try:
303
+ req_context = session.request(
304
+ method,
305
+ request_url,
306
+ headers=headers,
307
+ **kwargs,
308
+ )
309
+ response = await req_context.__aenter__()
310
+
311
+ await self._update_last_token_cookie(response)
312
+ if auto_close:
313
+ try:
314
+ _LOGGER.debug(
315
+ "%s %s %s",
316
+ response.status,
317
+ response.content_type,
318
+ response,
319
+ )
320
+ response.release()
321
+ except Exception:
322
+ # make sure response is released
323
+ response.release()
324
+ # re-raise exception
325
+ raise
326
+
327
+ return response
328
+ except aiohttp.ServerDisconnectedError as err:
329
+ # If the server disconnected, try again
330
+ # since HTTP/1.1 allows the server to disconnect
331
+ # at any time
332
+ if attempt == 0:
333
+ continue
334
+ raise NvrError(
335
+ f"Error requesting data from {self._host}: {err}",
336
+ ) from err
337
+ except client_exceptions.ClientError as err:
338
+ raise NvrError(
339
+ f"Error requesting data from {self._host}: {err}",
340
+ ) from err
341
+
342
+ # should never happen
343
+ raise NvrError(f"Error requesting data from {self._host}")
344
+
345
+ async def api_request_raw(
346
+ self,
347
+ url: str,
348
+ method: str = "get",
349
+ require_auth: bool = True,
350
+ raise_exception: bool = True,
351
+ **kwargs: Any,
352
+ ) -> bytes | None:
353
+ """Make a request to UniFi Protect API"""
354
+ url = urljoin(self.api_path, url)
355
+ response = await self.request(
356
+ method,
357
+ url,
358
+ require_auth=require_auth,
359
+ auto_close=False,
360
+ **kwargs,
361
+ )
362
+
363
+ try:
364
+ if response.status != 200:
365
+ reason = await get_response_reason(response)
366
+ msg = "Request failed: %s - Status: %s - Reason: %s"
367
+ if raise_exception:
368
+ if response.status in {401, 403}:
369
+ raise NotAuthorized(msg % (url, response.status, reason))
370
+ if response.status >= 400 and response.status < 500:
371
+ raise BadRequest(msg % (url, response.status, reason))
372
+ raise NvrError(msg % (url, response.status, reason))
373
+ _LOGGER.debug(msg, url, response.status, reason)
374
+ return None
375
+
376
+ data: bytes | None = await response.read()
377
+ response.release()
378
+
379
+ return data
380
+ except Exception:
381
+ # make sure response is released
382
+ response.release()
383
+ # re-raise exception
384
+ raise
385
+
386
+ async def api_request(
387
+ self,
388
+ url: str,
389
+ method: str = "get",
390
+ require_auth: bool = True,
391
+ raise_exception: bool = True,
392
+ **kwargs: Any,
393
+ ) -> list[Any] | dict[str, Any] | None:
394
+ data = await self.api_request_raw(
395
+ url=url,
396
+ method=method,
397
+ require_auth=require_auth,
398
+ raise_exception=raise_exception,
399
+ **kwargs,
400
+ )
401
+
402
+ if data is not None:
403
+ json_data: list[Any] | dict[str, Any] = orjson.loads(data)
404
+ return json_data
405
+ return None
406
+
407
+ async def api_request_obj(
408
+ self,
409
+ url: str,
410
+ method: str = "get",
411
+ require_auth: bool = True,
412
+ raise_exception: bool = True,
413
+ **kwargs: Any,
414
+ ) -> dict[str, Any]:
415
+ data = await self.api_request(
416
+ url=url,
417
+ method=method,
418
+ require_auth=require_auth,
419
+ raise_exception=raise_exception,
420
+ **kwargs,
421
+ )
422
+
423
+ if not isinstance(data, dict):
424
+ raise NvrError(f"Could not decode object from {url}")
425
+
426
+ return data
427
+
428
+ async def api_request_list(
429
+ self,
430
+ url: str,
431
+ method: str = "get",
432
+ require_auth: bool = True,
433
+ raise_exception: bool = True,
434
+ **kwargs: Any,
435
+ ) -> list[Any]:
436
+ data = await self.api_request(
437
+ url=url,
438
+ method=method,
439
+ require_auth=require_auth,
440
+ raise_exception=raise_exception,
441
+ **kwargs,
442
+ )
443
+
444
+ if not isinstance(data, list):
445
+ raise NvrError(f"Could not decode list from {url}")
446
+
447
+ return data
448
+
449
+ async def ensure_authenticated(self) -> None:
450
+ """Ensure we are authenticated."""
451
+ await self._load_session()
452
+ if self.is_authenticated() is False:
453
+ await self.authenticate()
454
+
455
+ async def authenticate(self) -> None:
456
+ """Authenticate and get a token."""
457
+ if self._auth_lock.locked():
458
+ # If an auth is already in progress
459
+ # do not start another one
460
+ async with self._auth_lock:
461
+ return
462
+
463
+ async with self._auth_lock:
464
+ url = "/api/auth/login"
465
+
466
+ if self._session is not None:
467
+ self._session.cookie_jar.clear()
468
+ self.set_header("cookie", None)
469
+
470
+ auth = {
471
+ "username": self._username,
472
+ "password": self._password,
473
+ "rememberMe": self.store_sessions,
474
+ }
475
+
476
+ response = await self.request("post", url=url, json=auth)
477
+ self.set_header("cookie", response.headers.get("set-cookie", ""))
478
+ self._is_authenticated = True
479
+ await self._update_last_token_cookie(response)
480
+ _LOGGER.debug("Authenticated successfully!")
481
+
482
+ async def _update_last_token_cookie(self, response: aiohttp.ClientResponse) -> None:
483
+ """Update the last token cookie."""
484
+ csrf_token = response.headers.get("x-csrf-token")
485
+ if (
486
+ csrf_token is not None
487
+ and self.headers
488
+ and csrf_token != self.headers.get("x-csrf-token")
489
+ ):
490
+ self.set_header("x-csrf-token", csrf_token)
491
+ await self._update_last_token_cookie(response)
492
+
493
+ if (
494
+ token_cookie := response.cookies.get("TOKEN")
495
+ ) and token_cookie != self._last_token_cookie:
496
+ self._last_token_cookie = token_cookie
497
+ if self.store_sessions:
498
+ await self._update_auth_config(self._last_token_cookie)
499
+ self._last_token_cookie_decode = None
500
+
501
+ async def _update_auth_config(self, cookie: Morsel[str]) -> None:
502
+ """Updates auth cookie on disk for persistent sessions."""
503
+ if self._last_token_cookie is None:
504
+ return
505
+
506
+ await aos.makedirs(self.config_dir, exist_ok=True)
507
+
508
+ config: dict[str, Any] = {}
509
+ session_hash = get_user_hash(str(self._url), self._username)
510
+ try:
511
+ async with aiofiles.open(self.config_file, "rb") as f:
512
+ config_data = await f.read()
513
+ if config_data:
514
+ try:
515
+ config = orjson.loads(config_data)
516
+ except Exception:
517
+ _LOGGER.warning("Invalid config file, ignoring.")
518
+ except FileNotFoundError:
519
+ pass
520
+
521
+ config["sessions"] = config.get("sessions", {})
522
+ config["sessions"][session_hash] = {
523
+ "metadata": dict(cookie),
524
+ "value": cookie.value,
525
+ "csrf": self.headers.get("x-csrf-token") if self.headers else None,
526
+ }
527
+
528
+ async with aiofiles.open(self.config_file, "wb") as f:
529
+ await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2))
530
+
531
+ async def _load_session(self) -> None:
532
+ if self._session is None:
533
+ await self.get_session()
534
+ assert self._session is not None
535
+
536
+ if not self._loaded_session and self.store_sessions:
537
+ session_cookie = await self._read_auth_config()
538
+ self._loaded_session = True
539
+ if session_cookie:
540
+ _LOGGER.debug("Successfully loaded session from config")
541
+ self._session.cookie_jar.update_cookies(session_cookie)
542
+
543
+ async def _read_auth_config(self) -> SimpleCookie | None:
544
+ """Read auth cookie from config."""
545
+ try:
546
+ async with aiofiles.open(self.config_file, "rb") as f:
547
+ config_data = await f.read()
548
+ if config_data:
549
+ try:
550
+ config = orjson.loads(config_data)
551
+ except Exception:
552
+ _LOGGER.warning("Invalid config file, ignoring.")
553
+ return None
554
+ except FileNotFoundError:
555
+ _LOGGER.debug("no config file, not loading session")
556
+ return None
557
+
558
+ session_hash = get_user_hash(str(self._url), self._username)
559
+ session = config.get("sessions", {}).get(session_hash)
560
+ if not session:
561
+ _LOGGER.debug("No existing session for %s", session_hash)
562
+ return None
563
+
564
+ cookie = SimpleCookie()
565
+ cookie["TOKEN"] = session.get("value")
566
+ for key, value in session.get("metadata", {}).items():
567
+ cookie["TOKEN"][key] = value
568
+
569
+ cookie_value = _COOKIE_RE.sub("", str(cookie["TOKEN"]))
570
+ self._last_token_cookie = cookie["TOKEN"]
571
+ self._last_token_cookie_decode = None
572
+ self._is_authenticated = True
573
+ self.set_header("cookie", cookie_value)
574
+ if session.get("csrf"):
575
+ self.set_header("x-csrf-token", session["csrf"])
576
+ return cookie
577
+
578
+ def is_authenticated(self) -> bool:
579
+ """Check to see if we are already authenticated."""
580
+ if self._session is None:
581
+ return False
582
+
583
+ if self._is_authenticated is False:
584
+ return False
585
+
586
+ if self._last_token_cookie is None:
587
+ return False
588
+
589
+ # Lazy decode the token cookie
590
+ if self._last_token_cookie and self._last_token_cookie_decode is None:
591
+ self._last_token_cookie_decode = decode_token_cookie(
592
+ self._last_token_cookie,
593
+ )
594
+
595
+ if (
596
+ self._last_token_cookie_decode is None
597
+ or "exp" not in self._last_token_cookie_decode
598
+ ):
599
+ return False
600
+
601
+ token_expires_at = cast(int, self._last_token_cookie_decode["exp"])
602
+ max_expire_time = time.time() + TOKEN_COOKIE_MAX_EXP_SECONDS
603
+
604
+ return token_expires_at >= max_expire_time
605
+
606
+ async def async_connect_ws(self, force: bool) -> None:
607
+ """Connect to Websocket."""
608
+ if force and self._websocket is not None:
609
+ await self._websocket.disconnect()
610
+ self._websocket = None
611
+
612
+ websocket = await self.get_websocket()
613
+ # important to make sure WS URL is always current
614
+ websocket.url = self.ws_url
615
+
616
+ if not websocket.is_connected:
617
+ self._last_ws_status = False
618
+ with contextlib.suppress(
619
+ TimeoutError,
620
+ asyncio.TimeoutError,
621
+ asyncio.CancelledError,
622
+ ):
623
+ await websocket.connect()
624
+
625
+ async def async_disconnect_ws(self) -> None:
626
+ """Disconnect from Websocket."""
627
+ if self._websocket is None:
628
+ return
629
+ await self._websocket.disconnect()
630
+
631
+ def check_ws(self) -> bool:
632
+ """Checks current state of Websocket."""
633
+ if self._websocket is None:
634
+ return False
635
+
636
+ if not self._websocket.is_connected:
637
+ log = _LOGGER.debug
638
+ if self._last_ws_status:
639
+ log = _LOGGER.warning
640
+ log("Websocket connection not active, failing back to polling")
641
+ elif not self._last_ws_status:
642
+ _LOGGER.info("Websocket re-connected successfully")
643
+
644
+ self._last_ws_status = self._websocket.is_connected
645
+ return self._last_ws_status
646
+
647
+ def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
648
+ raise NotImplementedError
649
+
650
+ def _get_last_update_id(self) -> UUID | None:
651
+ raise NotImplementedError
652
+
653
+
654
+ class ProtectApiClient(BaseApiClient):
655
+ """
656
+ Main UFP API Client
657
+
658
+ UniFi Protect is a full async application. "normal" use of interacting with it is
659
+ to call `.update()` which will initialize the `.bootstrap` and create a Websocket
660
+ connection to UFP. This Websocket connection will emit messages that will automatically
661
+ update the `.bootstrap` over time. Caling `.udpate` again (without `force`) will
662
+ verify the integry of the Websocket connection.
663
+
664
+ You can use the `.get_` methods to one off pull devices from the UFP API, but should
665
+ not be used for building an aplication on top of.
666
+
667
+ All objects inside of `.bootstrap` have a refernce back to the API client so they can
668
+ use `.save_device()` and update themselves using their own `.set_` methods on the object.
669
+
670
+ Args:
671
+ ----
672
+ host: UFP hostname / IP address
673
+ port: UFP HTTPS port
674
+ username: UFP username
675
+ password: UFP password
676
+ verify_ssl: Verify HTTPS certificate (default: `True`)
677
+ session: Optional aiohttp session to use (default: generate one)
678
+ override_connection_host: Use `host` as your `connection_host` for RTSP stream instead of using the one provided by UniFi Protect.
679
+ minimum_score: minimum score for events (default: `0`)
680
+ subscribed_models: Model types you want to filter events for WS. You will need to manually check the bootstrap for updates for events that not subscibred.
681
+ ignore_stats: Ignore storage, system, etc. stats/metrics from NVR and cameras (default: false)
682
+ debug: Use full type validation (default: false)
683
+
684
+ """
685
+
686
+ _minimum_score: int
687
+ _subscribed_models: set[ModelType]
688
+ _ignore_stats: bool
689
+ _ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
690
+ _bootstrap: Bootstrap | None = None
691
+ _last_update_dt: datetime | None = None
692
+ _connection_host: IPv4Address | IPv6Address | str | None = None
693
+
694
+ ignore_unadopted: bool
695
+
696
+ def __init__(
697
+ self,
698
+ host: str,
699
+ port: int,
700
+ username: str,
701
+ password: str,
702
+ verify_ssl: bool = True,
703
+ session: aiohttp.ClientSession | None = None,
704
+ ws_timeout: int = 30,
705
+ cache_dir: Path | None = None,
706
+ config_dir: Path | None = None,
707
+ store_sessions: bool = True,
708
+ override_connection_host: bool = False,
709
+ minimum_score: int = 0,
710
+ subscribed_models: set[ModelType] | None = None,
711
+ ignore_stats: bool = False,
712
+ ignore_unadopted: bool = True,
713
+ debug: bool = False,
714
+ ) -> None:
715
+ super().__init__(
716
+ host=host,
717
+ port=port,
718
+ username=username,
719
+ password=password,
720
+ verify_ssl=verify_ssl,
721
+ session=session,
722
+ ws_timeout=ws_timeout,
723
+ cache_dir=cache_dir,
724
+ config_dir=config_dir,
725
+ store_sessions=store_sessions,
726
+ )
727
+
728
+ self._minimum_score = minimum_score
729
+ self._subscribed_models = subscribed_models or set()
730
+ self._ignore_stats = ignore_stats
731
+ self._ws_subscriptions = []
732
+ self.ignore_unadopted = ignore_unadopted
733
+
734
+ if override_connection_host:
735
+ self._connection_host = ip_from_host(self._host)
736
+
737
+ if debug:
738
+ set_debug()
739
+
740
+ @property
741
+ def is_ready(self) -> bool:
742
+ return self._bootstrap is not None
743
+
744
+ @property
745
+ def bootstrap(self) -> Bootstrap:
746
+ if self._bootstrap is None:
747
+ raise BadRequest("Client not initalized, run `update` first")
748
+
749
+ return self._bootstrap
750
+
751
+ @property
752
+ def connection_host(self) -> IPv4Address | IPv6Address | str:
753
+ """Connection host to use for generating RTSP URLs"""
754
+ if self._connection_host is None:
755
+ # fallback if cannot find user supplied host
756
+ index = 0
757
+ try:
758
+ # check if user supplied host is avaiable
759
+ index = self.bootstrap.nvr.hosts.index(self._host)
760
+ except ValueError:
761
+ # check if IP of user supplied host is avaiable
762
+ host = ip_from_host(self._host)
763
+ with contextlib.suppress(ValueError):
764
+ index = self.bootstrap.nvr.hosts.index(host)
765
+
766
+ self._connection_host = self.bootstrap.nvr.hosts[index]
767
+
768
+ return self._connection_host
769
+
770
+ async def update(self, force: bool = False) -> Bootstrap | None:
771
+ """
772
+ Updates the state of devices, initalizes `.bootstrap` and
773
+ connects to UFP Websocket for real time updates
774
+
775
+ You can use the various other `get_` methods if you need one off data from UFP
776
+ """
777
+ now = time.monotonic()
778
+ now_dt = utc_now()
779
+ max_event_dt = now_dt - timedelta(hours=1)
780
+ if force:
781
+ self._last_update = NEVER_RAN
782
+ self._last_update_dt = max_event_dt
783
+
784
+ bootstrap_updated = False
785
+ if self._bootstrap is None or now - self._last_update > DEVICE_UPDATE_INTERVAL:
786
+ bootstrap_updated = True
787
+ self._bootstrap = await self.get_bootstrap()
788
+ self._last_update = now
789
+ self._last_update_dt = now_dt
790
+
791
+ await self.async_connect_ws(force)
792
+ if self.check_ws():
793
+ # If the websocket is connected/connecting
794
+ # we do not need to get events
795
+ _LOGGER.debug("Skipping update since websocket is active")
796
+ return None
797
+
798
+ if bootstrap_updated:
799
+ return None
800
+
801
+ events = await self.get_events(
802
+ start=self._last_update_dt or max_event_dt,
803
+ end=now_dt,
804
+ )
805
+ for event in events:
806
+ self.bootstrap.process_event(event)
807
+
808
+ self._last_update = now
809
+ self._last_update_dt = now_dt
810
+ return self._bootstrap
811
+
812
+ def emit_message(self, msg: WSSubscriptionMessage) -> None:
813
+ if msg.new_obj is not None:
814
+ _LOGGER.debug(
815
+ "emitting message: %s:%s:%s:%s",
816
+ msg.action,
817
+ msg.new_obj.model,
818
+ msg.new_obj.id,
819
+ list(msg.changed_data.keys()),
820
+ )
821
+ elif msg.old_obj is not None:
822
+ _LOGGER.debug(
823
+ "emitting message: %s:%s:%s",
824
+ msg.action,
825
+ msg.old_obj.model,
826
+ msg.old_obj.id,
827
+ )
828
+ else:
829
+ _LOGGER.debug("emitting message: %s", msg.action)
830
+ for sub in self._ws_subscriptions:
831
+ try:
832
+ sub(msg)
833
+ except Exception:
834
+ _LOGGER.exception("Exception while running subscription handler")
835
+
836
+ def _get_last_update_id(self) -> UUID | None:
837
+ if self._bootstrap is None:
838
+ return None
839
+ return self._bootstrap.last_update_id
840
+
841
+ def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
842
+ packet = WSPacket(msg.data)
843
+ processed_message = self.bootstrap.process_ws_packet(
844
+ packet,
845
+ models=self._subscribed_models,
846
+ ignore_stats=self._ignore_stats,
847
+ )
848
+ # update websocket URL after every message to ensure the latest last_update_id
849
+ if self._websocket is not None:
850
+ self._websocket.url = self.ws_url
851
+
852
+ if processed_message is None:
853
+ return
854
+
855
+ self.emit_message(processed_message)
856
+
857
+ async def _get_event_paginate(
858
+ self,
859
+ params: dict[str, Any],
860
+ *,
861
+ start: datetime,
862
+ end: datetime | None,
863
+ ) -> list[dict[str, Any]]:
864
+ start_int = to_js_time(start)
865
+ end_int = to_js_time(end) if end else None
866
+ offset = 0
867
+ current_start = sys.maxsize
868
+ events: list[dict[str, Any]] = []
869
+ request_count = 0
870
+ logged = False
871
+
872
+ params["limit"] = 100
873
+ # greedy algorithm
874
+ # always force desc to receive faster results in the vast majority of cases
875
+ params["orderDirection"] = "DESC"
876
+
877
+ _LOGGER.debug("paginate desc %s %s", start_int, end_int)
878
+ while current_start > start_int:
879
+ params["offset"] = offset
880
+
881
+ _LOGGER.debug("page desc %s %s", offset, current_start)
882
+ new_events = await self.api_request_list("events", params=params)
883
+ request_count += 1
884
+ if not new_events:
885
+ break
886
+
887
+ if end_int is not None:
888
+ _LOGGER.debug("page end %s (%s)", new_events[0]["end"], end_int)
889
+ for event in new_events:
890
+ if event["start"] <= end_int:
891
+ events.append(event)
892
+ else:
893
+ break
894
+ else:
895
+ events += new_events
896
+
897
+ offset += 100
898
+ if events:
899
+ current_start = events[-1]["start"]
900
+ if not logged and request_count > 5:
901
+ logged = True
902
+ _LOGGER.warning(TYPES_BUG_MESSAGE)
903
+
904
+ to_remove = 0
905
+ for event in reversed(events):
906
+ if event["start"] < start_int:
907
+ to_remove += 1
908
+ else:
909
+ break
910
+ if to_remove:
911
+ events = events[:-to_remove]
912
+
913
+ return events
914
+
915
+ async def get_events_raw(
916
+ self,
917
+ *,
918
+ start: datetime | None = None,
919
+ end: datetime | None = None,
920
+ limit: int | None = None,
921
+ offset: int | None = None,
922
+ types: list[EventType] | None = None,
923
+ smart_detect_types: list[SmartDetectObjectType] | None = None,
924
+ sorting: Literal["asc", "desc"] = "asc",
925
+ descriptions: bool = True,
926
+ all_cameras: bool | None = None,
927
+ category: EventCategories | None = None,
928
+ # used for testing
929
+ _allow_manual_paginate: bool = True,
930
+ ) -> list[dict[str, Any]]:
931
+ """
932
+ Get list of events from Protect
933
+
934
+ Args:
935
+ ----
936
+ start: start time for events
937
+ end: end time for events
938
+ limit: max number of events to return
939
+ offset: offset to start fetching events from
940
+ types: list of EventTypes to get events for
941
+ smart_detect_types: Filters the Smart detection types for the events
942
+ sorting: sort events by ascending or decending, defaults to ascending (chronologic order)
943
+ description: included additional event metadata
944
+ category: event category, will provide additional category/subcategory fields
945
+
946
+
947
+ If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours.
948
+
949
+ If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or
950
+ `limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect
951
+
952
+ """
953
+ # if no parameters are passed in, default to all events from last 24 hours
954
+ if limit is None and start is None and end is None:
955
+ end = utc_now() + timedelta(seconds=10)
956
+ start = end - timedelta(hours=1)
957
+
958
+ params: dict[str, Any] = {
959
+ "orderDirection": sorting.upper(),
960
+ "withoutDescriptions": str(not descriptions).lower(),
961
+ }
962
+ if limit is not None:
963
+ params["limit"] = limit
964
+ if offset is not None:
965
+ params["offset"] = offset
966
+
967
+ if start is not None:
968
+ params["start"] = to_js_time(start)
969
+
970
+ if end is not None:
971
+ params["end"] = to_js_time(end)
972
+
973
+ if types is not None:
974
+ params["types"] = [e.value for e in types]
975
+
976
+ if smart_detect_types is not None:
977
+ params["smartDetectTypes"] = [e.value for e in smart_detect_types]
978
+
979
+ if all_cameras is not None:
980
+ params["allCameras"] = str(all_cameras).lower()
981
+
982
+ if category is not None:
983
+ params["categories"] = category
984
+
985
+ # manual workaround for a UniFi Protect bug
986
+ # if types if missing from query params
987
+ if _allow_manual_paginate and "types" not in params and start is not None:
988
+ if sorting == "asc":
989
+ events = await self._get_event_paginate(
990
+ params,
991
+ start=start,
992
+ end=end,
993
+ )
994
+ events = list(reversed(events))
995
+ else:
996
+ events = await self._get_event_paginate(
997
+ params,
998
+ start=start,
999
+ end=end,
1000
+ )
1001
+
1002
+ if limit:
1003
+ offset = offset or 0
1004
+ events = events[offset : limit + offset]
1005
+ elif offset:
1006
+ events = events[offset:]
1007
+ return events
1008
+
1009
+ return await self.api_request_list("events", params=params)
1010
+
1011
+ async def get_events(
1012
+ self,
1013
+ start: datetime | None = None,
1014
+ end: datetime | None = None,
1015
+ limit: int | None = None,
1016
+ offset: int | None = None,
1017
+ types: list[EventType] | None = None,
1018
+ smart_detect_types: list[SmartDetectObjectType] | None = None,
1019
+ sorting: Literal["asc", "desc"] = "asc",
1020
+ descriptions: bool = True,
1021
+ category: EventCategories | None = None,
1022
+ # used for testing
1023
+ _allow_manual_paginate: bool = True,
1024
+ ) -> list[Event]:
1025
+ """
1026
+ Same as `get_events_raw`, except
1027
+
1028
+ * returns actual `Event` objects instead of raw Python dictionaries
1029
+ * filers out non-device events
1030
+ * filters out events with too low of a score
1031
+
1032
+ Args:
1033
+ ----
1034
+ start: start time for events
1035
+ end: end time for events
1036
+ limit: max number of events to return
1037
+ offset: offset to start fetching events from
1038
+ types: list of EventTypes to get events for
1039
+ smart_detect_types: Filters the Smart detection types for the events
1040
+ sorting: sort events by ascending or decending, defaults to ascending (chronologic order)
1041
+ description: included additional event metadata
1042
+ category: event category, will provide additional category/subcategory fields
1043
+
1044
+
1045
+ If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours.
1046
+
1047
+ If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or
1048
+ `limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect
1049
+
1050
+ """
1051
+ response = await self.get_events_raw(
1052
+ start=start,
1053
+ end=end,
1054
+ limit=limit,
1055
+ offset=offset,
1056
+ types=types,
1057
+ smart_detect_types=smart_detect_types,
1058
+ sorting=sorting,
1059
+ descriptions=descriptions,
1060
+ category=category,
1061
+ _allow_manual_paginate=_allow_manual_paginate,
1062
+ )
1063
+ events = []
1064
+
1065
+ for event_dict in response:
1066
+ # ignore unknown events
1067
+ if "type" not in event_dict or event_dict["type"] not in EventType.values():
1068
+ _LOGGER.debug("Unknown event type: %s", event_dict)
1069
+ continue
1070
+
1071
+ event = create_from_unifi_dict(event_dict, api=self)
1072
+
1073
+ # should never happen
1074
+ if not isinstance(event, Event):
1075
+ continue
1076
+
1077
+ if (
1078
+ event.type.value in EventType.device_events()
1079
+ and event.score >= self._minimum_score
1080
+ ):
1081
+ events.append(event)
1082
+
1083
+ return events
1084
+
1085
+ def subscribe_websocket(
1086
+ self,
1087
+ ws_callback: Callable[[WSSubscriptionMessage], None],
1088
+ ) -> Callable[[], None]:
1089
+ """
1090
+ Subscribe to websocket events.
1091
+
1092
+ Returns a callback that will unsubscribe.
1093
+ """
1094
+
1095
+ def _unsub_ws_callback() -> None:
1096
+ self._ws_subscriptions.remove(ws_callback)
1097
+
1098
+ _LOGGER.debug("Adding subscription: %s", ws_callback)
1099
+ self._ws_subscriptions.append(ws_callback)
1100
+ return _unsub_ws_callback
1101
+
1102
+ async def get_bootstrap(self) -> Bootstrap:
1103
+ """
1104
+ Gets bootstrap object from UFP instance
1105
+
1106
+ This is a great alternative if you need metadata about the NVR without connecting to the Websocket
1107
+ """
1108
+ data = await self.api_request_obj("bootstrap")
1109
+ # fix for UniFi Protect bug, some cameras may come back with and old recording mode
1110
+ # "motion" and "smartDetect" recording modes was combined into "detections" in Protect 1.20.0
1111
+ call_again = False
1112
+ for camera_dict in data["cameras"]:
1113
+ if camera_dict.get("recordingSettings", {}).get("mode", "detections") in {
1114
+ "motion",
1115
+ "smartDetect",
1116
+ }:
1117
+ await self.update_device(
1118
+ ModelType.CAMERA,
1119
+ camera_dict["id"],
1120
+ {"recordingSettings": {"mode": RecordingMode.DETECTIONS.value}},
1121
+ )
1122
+ call_again = True
1123
+
1124
+ if call_again:
1125
+ data = await self.api_request_obj("bootstrap")
1126
+ return Bootstrap.from_unifi_dict(**data, api=self)
1127
+
1128
+ async def get_devices_raw(self, model_type: ModelType) -> list[dict[str, Any]]:
1129
+ """Gets a raw device list given a model_type"""
1130
+ return await self.api_request_list(f"{model_type.value}s")
1131
+
1132
+ async def get_devices(
1133
+ self,
1134
+ model_type: ModelType,
1135
+ expected_type: type[ProtectModel] | None = None,
1136
+ ) -> list[ProtectModel]:
1137
+ """Gets a device list given a model_type, converted into Python objects"""
1138
+ objs: list[ProtectModel] = []
1139
+
1140
+ for obj_dict in await self.get_devices_raw(model_type):
1141
+ obj = create_from_unifi_dict(obj_dict)
1142
+
1143
+ if expected_type is not None and not isinstance(obj, expected_type):
1144
+ raise NvrError(f"Unexpected model returned: {obj.model}")
1145
+ if (
1146
+ self.ignore_unadopted
1147
+ and isinstance(obj, ProtectAdoptableDeviceModel)
1148
+ and not obj.is_adopted
1149
+ ):
1150
+ continue
1151
+
1152
+ objs.append(obj)
1153
+
1154
+ return objs
1155
+
1156
+ async def get_cameras(self) -> list[Camera]:
1157
+ """
1158
+ Gets the list of cameras straight from the NVR.
1159
+
1160
+ The websocket is connected and running, you likely just want to use `self.bootstrap.cameras`
1161
+ """
1162
+ return cast(list[Camera], await self.get_devices(ModelType.CAMERA, Camera))
1163
+
1164
+ async def get_lights(self) -> list[Light]:
1165
+ """
1166
+ Gets the list of lights straight from the NVR.
1167
+
1168
+ The websocket is connected and running, you likely just want to use `self.bootstrap.lights`
1169
+ """
1170
+ return cast(list[Light], await self.get_devices(ModelType.LIGHT, Light))
1171
+
1172
+ async def get_sensors(self) -> list[Sensor]:
1173
+ """
1174
+ Gets the list of sensors straight from the NVR.
1175
+
1176
+ The websocket is connected and running, you likely just want to use `self.bootstrap.sensors`
1177
+ """
1178
+ return cast(list[Sensor], await self.get_devices(ModelType.SENSOR, Sensor))
1179
+
1180
+ async def get_doorlocks(self) -> list[Doorlock]:
1181
+ """
1182
+ Gets the list of doorlocks straight from the NVR.
1183
+
1184
+ The websocket is connected and running, you likely just want to use `self.bootstrap.doorlocks`
1185
+ """
1186
+ return cast(
1187
+ list[Doorlock],
1188
+ await self.get_devices(ModelType.DOORLOCK, Doorlock),
1189
+ )
1190
+
1191
+ async def get_chimes(self) -> list[Chime]:
1192
+ """
1193
+ Gets the list of chimes straight from the NVR.
1194
+
1195
+ The websocket is connected and running, you likely just want to use `self.bootstrap.chimes`
1196
+ """
1197
+ return cast(list[Chime], await self.get_devices(ModelType.CHIME, Chime))
1198
+
1199
+ async def get_viewers(self) -> list[Viewer]:
1200
+ """
1201
+ Gets the list of viewers straight from the NVR.
1202
+
1203
+ The websocket is connected and running, you likely just want to use `self.bootstrap.viewers`
1204
+ """
1205
+ return cast(list[Viewer], await self.get_devices(ModelType.VIEWPORT, Viewer))
1206
+
1207
+ async def get_bridges(self) -> list[Bridge]:
1208
+ """
1209
+ Gets the list of bridges straight from the NVR.
1210
+
1211
+ The websocket is connected and running, you likely just want to use `self.bootstrap.bridges`
1212
+ """
1213
+ return cast(list[Bridge], await self.get_devices(ModelType.BRIDGE, Bridge))
1214
+
1215
+ async def get_liveviews(self) -> list[Liveview]:
1216
+ """
1217
+ Gets the list of liveviews straight from the NVR.
1218
+
1219
+ The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews`
1220
+ """
1221
+ return cast(
1222
+ list[Liveview],
1223
+ await self.get_devices(ModelType.LIVEVIEW, Liveview),
1224
+ )
1225
+
1226
+ async def get_device_raw(
1227
+ self,
1228
+ model_type: ModelType,
1229
+ device_id: str,
1230
+ ) -> dict[str, Any]:
1231
+ """Gets a raw device give the device model_type and id"""
1232
+ return await self.api_request_obj(f"{model_type.value}s/{device_id}")
1233
+
1234
+ async def get_device(
1235
+ self,
1236
+ model_type: ModelType,
1237
+ device_id: str,
1238
+ expected_type: type[ProtectModelWithId] | None = None,
1239
+ ) -> ProtectModelWithId:
1240
+ """Gets a device give the device model_type and id, converted into Python object"""
1241
+ obj = create_from_unifi_dict(
1242
+ await self.get_device_raw(model_type, device_id),
1243
+ api=self,
1244
+ )
1245
+
1246
+ if expected_type is not None and not isinstance(obj, expected_type):
1247
+ raise NvrError(f"Unexpected model returned: {obj.model}")
1248
+ if (
1249
+ self.ignore_unadopted
1250
+ and isinstance(obj, ProtectAdoptableDeviceModel)
1251
+ and not obj.is_adopted
1252
+ ):
1253
+ raise NvrError("Device is not adopted")
1254
+
1255
+ return cast(ProtectModelWithId, obj)
1256
+
1257
+ async def get_nvr(self) -> NVR:
1258
+ """
1259
+ Gets an NVR object straight from the NVR.
1260
+
1261
+ This is a great alternative if you need metadata about the NVR without connecting to the Websocket
1262
+ """
1263
+ data = await self.api_request_obj("nvr")
1264
+ return NVR.from_unifi_dict(**data, api=self)
1265
+
1266
+ async def get_event(self, event_id: str) -> Event:
1267
+ """
1268
+ Gets an event straight from the NVR.
1269
+
1270
+ This is a great alternative if the event is no longer in the `self.bootstrap.events[event_id]` cache
1271
+ """
1272
+ return cast(Event, await self.get_device(ModelType.EVENT, event_id, Event))
1273
+
1274
+ async def get_camera(self, device_id: str) -> Camera:
1275
+ """
1276
+ Gets a camera straight from the NVR.
1277
+
1278
+ The websocket is connected and running, you likely just want to use `self.bootstrap.cameras[device_id]`
1279
+ """
1280
+ return cast(Camera, await self.get_device(ModelType.CAMERA, device_id, Camera))
1281
+
1282
+ async def get_light(self, device_id: str) -> Light:
1283
+ """
1284
+ Gets a light straight from the NVR.
1285
+
1286
+ The websocket is connected and running, you likely just want to use `self.bootstrap.lights[device_id]`
1287
+ """
1288
+ return cast(Light, await self.get_device(ModelType.LIGHT, device_id, Light))
1289
+
1290
+ async def get_sensor(self, device_id: str) -> Sensor:
1291
+ """
1292
+ Gets a sensor straight from the NVR.
1293
+
1294
+ The websocket is connected and running, you likely just want to use `self.bootstrap.sensors[device_id]`
1295
+ """
1296
+ return cast(Sensor, await self.get_device(ModelType.SENSOR, device_id, Sensor))
1297
+
1298
+ async def get_doorlock(self, device_id: str) -> Doorlock:
1299
+ """
1300
+ Gets a doorlock straight from the NVR.
1301
+
1302
+ The websocket is connected and running, you likely just want to use `self.bootstrap.doorlocks[device_id]`
1303
+ """
1304
+ return cast(
1305
+ Doorlock,
1306
+ await self.get_device(ModelType.DOORLOCK, device_id, Doorlock),
1307
+ )
1308
+
1309
+ async def get_chime(self, device_id: str) -> Chime:
1310
+ """
1311
+ Gets a chime straight from the NVR.
1312
+
1313
+ The websocket is connected and running, you likely just want to use `self.bootstrap.chimes[device_id]`
1314
+ """
1315
+ return cast(Chime, await self.get_device(ModelType.CHIME, device_id, Chime))
1316
+
1317
+ async def get_viewer(self, device_id: str) -> Viewer:
1318
+ """
1319
+ Gets a viewer straight from the NVR.
1320
+
1321
+ The websocket is connected and running, you likely just want to use `self.bootstrap.viewers[device_id]`
1322
+ """
1323
+ return cast(
1324
+ Viewer,
1325
+ await self.get_device(ModelType.VIEWPORT, device_id, Viewer),
1326
+ )
1327
+
1328
+ async def get_bridge(self, device_id: str) -> Bridge:
1329
+ """
1330
+ Gets a bridge straight from the NVR.
1331
+
1332
+ The websocket is connected and running, you likely just want to use `self.bootstrap.bridges[device_id]`
1333
+ """
1334
+ return cast(Bridge, await self.get_device(ModelType.BRIDGE, device_id, Bridge))
1335
+
1336
+ async def get_liveview(self, device_id: str) -> Liveview:
1337
+ """
1338
+ Gets a liveview straight from the NVR.
1339
+
1340
+ The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews[device_id]`
1341
+ """
1342
+ return cast(
1343
+ Liveview,
1344
+ await self.get_device(ModelType.LIVEVIEW, device_id, Liveview),
1345
+ )
1346
+
1347
+ async def get_camera_snapshot(
1348
+ self,
1349
+ camera_id: str,
1350
+ width: int | None = None,
1351
+ height: int | None = None,
1352
+ dt: datetime | None = None,
1353
+ ) -> bytes | None:
1354
+ """
1355
+ Gets snapshot for a camera.
1356
+
1357
+ Datetime of screenshot is approximate. It may be +/- a few seconds.
1358
+ """
1359
+ params = {
1360
+ "ts": to_js_time(dt or utc_now()),
1361
+ "force": "true",
1362
+ }
1363
+
1364
+ if width is not None:
1365
+ params.update({"w": width})
1366
+
1367
+ if height is not None:
1368
+ params.update({"h": height})
1369
+
1370
+ path = "snapshot"
1371
+ if dt is not None:
1372
+ path = "recording-snapshot"
1373
+ del params["force"]
1374
+
1375
+ return await self.api_request_raw(
1376
+ f"cameras/{camera_id}/{path}",
1377
+ params=params,
1378
+ raise_exception=False,
1379
+ )
1380
+
1381
+ async def get_package_camera_snapshot(
1382
+ self,
1383
+ camera_id: str,
1384
+ width: int | None = None,
1385
+ height: int | None = None,
1386
+ dt: datetime | None = None,
1387
+ ) -> bytes | None:
1388
+ """
1389
+ Gets snapshot from the package camera.
1390
+
1391
+ Datetime of screenshot is approximate. It may be +/- a few seconds.
1392
+ """
1393
+ params = {
1394
+ "ts": to_js_time(dt or utc_now()),
1395
+ "force": "true",
1396
+ }
1397
+
1398
+ if width is not None:
1399
+ params.update({"w": width})
1400
+
1401
+ if height is not None:
1402
+ params.update({"h": height})
1403
+
1404
+ path = "package-snapshot"
1405
+ if dt is not None:
1406
+ path = "recording-snapshot"
1407
+ del params["force"]
1408
+ params.update({"lens": 2})
1409
+
1410
+ return await self.api_request_raw(
1411
+ f"cameras/{camera_id}/{path}",
1412
+ params=params,
1413
+ raise_exception=False,
1414
+ )
1415
+
1416
+ async def _stream_response(
1417
+ self,
1418
+ response: aiohttp.ClientResponse,
1419
+ chunk_size: int,
1420
+ iterator_callback: IteratorCallback | None = None,
1421
+ progress_callback: ProgressCallback | None = None,
1422
+ ) -> None:
1423
+ total = response.content_length or 0
1424
+ current = 0
1425
+ if iterator_callback is not None:
1426
+ await iterator_callback(total, None)
1427
+ async for chunk in response.content.iter_chunked(chunk_size):
1428
+ step = len(chunk)
1429
+ current += step
1430
+ if iterator_callback is not None:
1431
+ await iterator_callback(total, chunk)
1432
+ if progress_callback is not None:
1433
+ await progress_callback(step, current, total)
1434
+
1435
+ async def get_camera_video(
1436
+ self,
1437
+ camera_id: str,
1438
+ start: datetime,
1439
+ end: datetime,
1440
+ channel_index: int = 0,
1441
+ validate_channel_id: bool = True,
1442
+ output_file: Path | None = None,
1443
+ iterator_callback: IteratorCallback | None = None,
1444
+ progress_callback: ProgressCallback | None = None,
1445
+ chunk_size: int = 65536,
1446
+ fps: int | None = None,
1447
+ ) -> bytes | None:
1448
+ """
1449
+ Exports MP4 video from a given camera at a specific time.
1450
+
1451
+ Start/End of video export are approximate. It may be +/- a few seconds.
1452
+
1453
+ It is recommended to provide a output file or progress callback for larger
1454
+ video clips, otherwise the full video must be downloaded to memory before
1455
+ being written.
1456
+
1457
+ Providing the `fps` parameter creates a "timelapse" export wtih the given FPS
1458
+ value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x
1459
+ (fps=20), and 600x (fps=40).
1460
+ """
1461
+ if validate_channel_id and self._bootstrap is not None:
1462
+ camera = self._bootstrap.cameras[camera_id]
1463
+ try:
1464
+ camera.channels[channel_index]
1465
+ except IndexError as e:
1466
+ raise BadRequest from e
1467
+
1468
+ params = {
1469
+ "camera": camera_id,
1470
+ "start": to_js_time(start),
1471
+ "end": to_js_time(end),
1472
+ }
1473
+
1474
+ if fps is not None:
1475
+ params["fps"] = fps
1476
+ params["type"] = "timelapse"
1477
+
1478
+ if channel_index == 3:
1479
+ params.update({"lens": 2})
1480
+ else:
1481
+ params.update({"channel": channel_index})
1482
+
1483
+ path = "video/export"
1484
+ if (
1485
+ iterator_callback is None
1486
+ and progress_callback is None
1487
+ and output_file is None
1488
+ ):
1489
+ return await self.api_request_raw(
1490
+ path,
1491
+ params=params,
1492
+ raise_exception=False,
1493
+ )
1494
+
1495
+ r = await self.request(
1496
+ "get",
1497
+ urljoin(self.api_path, path),
1498
+ auto_close=False,
1499
+ timeout=0,
1500
+ params=params,
1501
+ )
1502
+ if output_file is not None:
1503
+ async with aiofiles.open(output_file, "wb") as output:
1504
+
1505
+ async def callback(total: int, chunk: bytes | None) -> None:
1506
+ if iterator_callback is not None:
1507
+ await iterator_callback(total, chunk)
1508
+ if chunk is not None:
1509
+ await output.write(chunk)
1510
+
1511
+ await self._stream_response(r, chunk_size, callback, progress_callback)
1512
+ else:
1513
+ await self._stream_response(
1514
+ r,
1515
+ chunk_size,
1516
+ iterator_callback,
1517
+ progress_callback,
1518
+ )
1519
+ r.close()
1520
+ return None
1521
+
1522
+ async def _get_image_with_retry(
1523
+ self,
1524
+ path: str,
1525
+ retry_timeout: int = RETRY_TIMEOUT,
1526
+ **kwargs: Any,
1527
+ ) -> bytes | None:
1528
+ """
1529
+ Retries image request until it returns or timesout. Used for event images like thumbnails and heatmaps.
1530
+
1531
+ Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
1532
+ your retry timeout will always return None.
1533
+ """
1534
+ now = time.monotonic()
1535
+ timeout = now + retry_timeout
1536
+ data: bytes | None = None
1537
+ while data is None and now < timeout:
1538
+ data = await self.api_request_raw(path, raise_exception=False, **kwargs)
1539
+ if data is None:
1540
+ await asyncio.sleep(0.5)
1541
+ now = time.monotonic()
1542
+
1543
+ return data
1544
+
1545
+ async def get_event_thumbnail(
1546
+ self,
1547
+ thumbnail_id: str,
1548
+ width: int | None = None,
1549
+ height: int | None = None,
1550
+ retry_timeout: int = RETRY_TIMEOUT,
1551
+ ) -> bytes | None:
1552
+ """
1553
+ Gets given thumbanail from a given event.
1554
+
1555
+ Thumbnail response is a JPEG image.
1556
+
1557
+ Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
1558
+ your retry timeout will always return 404.
1559
+ """
1560
+ params: dict[str, Any] = {}
1561
+
1562
+ if width is not None:
1563
+ params.update({"w": width})
1564
+
1565
+ if height is not None:
1566
+ params.update({"h": height})
1567
+
1568
+ # old thumbnail URL use thumbnail ID, which is just `e-{event_id}`
1569
+ thumbnail_id = thumbnail_id.replace("e-", "")
1570
+ return await self._get_image_with_retry(
1571
+ f"events/{thumbnail_id}/thumbnail",
1572
+ params=params,
1573
+ retry_timeout=retry_timeout,
1574
+ )
1575
+
1576
+ async def get_event_animated_thumbnail(
1577
+ self,
1578
+ thumbnail_id: str,
1579
+ width: int | None = None,
1580
+ height: int | None = None,
1581
+ *,
1582
+ speedup: int = 10,
1583
+ retry_timeout: int = RETRY_TIMEOUT,
1584
+ ) -> bytes | None:
1585
+ """
1586
+ Gets given animated thumbanil from a given event.
1587
+
1588
+ Animated thumbnail response is a GIF image.
1589
+
1590
+ Note: thumbnails / do not generate _until after the event ends_. Events that last longer then
1591
+ your retry timeout will always return 404.
1592
+ """
1593
+ params: dict[str, Any] = {
1594
+ "keyFrameOnly": "true",
1595
+ "speedup": speedup,
1596
+ }
1597
+
1598
+ if width is not None:
1599
+ params.update({"w": width})
1600
+
1601
+ if height is not None:
1602
+ params.update({"h": height})
1603
+
1604
+ # old thumbnail URL use thumbnail ID, which is just `e-{event_id}`
1605
+ thumbnail_id = thumbnail_id.replace("e-", "")
1606
+ return await self._get_image_with_retry(
1607
+ f"events/{thumbnail_id}/animated-thumbnail",
1608
+ params=params,
1609
+ retry_timeout=retry_timeout,
1610
+ )
1611
+
1612
+ async def get_event_heatmap(
1613
+ self,
1614
+ heatmap_id: str,
1615
+ retry_timeout: int = RETRY_TIMEOUT,
1616
+ ) -> bytes | None:
1617
+ """
1618
+ Gets given heatmap from a given event.
1619
+
1620
+ Heatmap response is a PNG image.
1621
+
1622
+ Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
1623
+ your retry timeout will always return None.
1624
+ """
1625
+ # old heatmap URL use heatmap ID, which is just `e-{event_id}`
1626
+ heatmap_id = heatmap_id.replace("e-", "")
1627
+ return await self._get_image_with_retry(
1628
+ f"events/{heatmap_id}/heatmap",
1629
+ retry_timeout=retry_timeout,
1630
+ )
1631
+
1632
+ async def get_event_smart_detect_track_raw(self, event_id: str) -> dict[str, Any]:
1633
+ """Gets raw Smart Detect Track for a Smart Detection"""
1634
+ return await self.api_request_obj(f"events/{event_id}/smartDetectTrack")
1635
+
1636
+ async def get_event_smart_detect_track(self, event_id: str) -> SmartDetectTrack:
1637
+ """Gets raw Smart Detect Track for a Smart Detection"""
1638
+ data = await self.api_request_obj(f"events/{event_id}/smartDetectTrack")
1639
+
1640
+ return SmartDetectTrack.from_unifi_dict(api=self, **data)
1641
+
1642
+ async def update_device(
1643
+ self,
1644
+ model_type: ModelType,
1645
+ device_id: str,
1646
+ data: dict[str, Any],
1647
+ ) -> None:
1648
+ """
1649
+ Sends an update for a device back to UFP
1650
+
1651
+ USE WITH CAUTION, all possible combinations of updating objects have not been fully tested.
1652
+ May have unexpected side effects.
1653
+
1654
+ Tested updates have been added a methods on applicable devices.
1655
+ """
1656
+ await self.api_request(
1657
+ f"{model_type.value}s/{device_id}",
1658
+ method="patch",
1659
+ json=data,
1660
+ )
1661
+
1662
+ async def update_nvr(self, data: dict[str, Any]) -> None:
1663
+ """
1664
+ Sends an update for main UFP NVR device
1665
+
1666
+ USE WITH CAUTION, all possible combinations of updating objects have not been fully tested.
1667
+ May have unexpected side effects.
1668
+
1669
+ Tested updates have been added a methods on applicable devices.
1670
+ """
1671
+ await self.api_request("nvr", method="patch", json=data)
1672
+
1673
+ async def reboot_nvr(self) -> None:
1674
+ """Reboots NVR"""
1675
+ await self.api_request("nvr/reboot", method="post")
1676
+
1677
+ async def reboot_device(self, model_type: ModelType, device_id: str) -> None:
1678
+ """Reboots an adopted device"""
1679
+ await self.api_request(f"{model_type.value}s/{device_id}/reboot", method="post")
1680
+
1681
+ async def unadopt_device(self, model_type: ModelType, device_id: str) -> None:
1682
+ """Unadopt/Unmanage adopted device"""
1683
+ await self.api_request(f"{model_type.value}s/{device_id}", method="delete")
1684
+
1685
+ async def adopt_device(self, model_type: ModelType, device_id: str) -> None:
1686
+ """Adopts a device"""
1687
+ key = f"{model_type.value}s"
1688
+ data = await self.api_request_obj(
1689
+ "devices/adopt",
1690
+ method="post",
1691
+ json={key: {device_id: {}}},
1692
+ )
1693
+
1694
+ if not data.get(key, {}).get(device_id, {}).get("adopted", False):
1695
+ raise BadRequest("Could not adopt device")
1696
+
1697
+ async def close_lock(self, device_id: str) -> None:
1698
+ """Close doorlock (lock)"""
1699
+ await self.api_request(f"doorlocks/{device_id}/close", method="post")
1700
+
1701
+ async def open_lock(self, device_id: str) -> None:
1702
+ """Open doorlock (unlock)"""
1703
+ await self.api_request(f"doorlocks/{device_id}/open", method="post")
1704
+
1705
+ async def calibrate_lock(self, device_id: str) -> None:
1706
+ """
1707
+ Calibrate the doorlock.
1708
+
1709
+ Door must be open and lock unlocked.
1710
+ """
1711
+ await self.api_request(
1712
+ f"doorlocks/{device_id}/calibrate",
1713
+ method="post",
1714
+ json={"auto": True},
1715
+ )
1716
+
1717
+ async def play_speaker(
1718
+ self,
1719
+ device_id: str,
1720
+ *,
1721
+ volume: int | None = None,
1722
+ repeat_times: int | None = None,
1723
+ ) -> None:
1724
+ """Plays chime tones on a chime"""
1725
+ data: dict[str, Any] | None = None
1726
+ if volume or repeat_times:
1727
+ chime = self.bootstrap.chimes.get(device_id)
1728
+ if chime is None:
1729
+ raise BadRequest("Invalid chime ID %s", device_id)
1730
+
1731
+ data = {
1732
+ "volume": volume or chime.volume,
1733
+ "repeatTimes": repeat_times or chime.repeat_times,
1734
+ "trackNo": chime.track_no,
1735
+ }
1736
+
1737
+ await self.api_request(
1738
+ f"chimes/{device_id}/play-speaker",
1739
+ method="post",
1740
+ json=data,
1741
+ )
1742
+
1743
+ async def play_buzzer(self, device_id: str) -> None:
1744
+ """Plays chime tones on a chime"""
1745
+ await self.api_request(f"chimes/{device_id}/play-buzzer", method="post")
1746
+
1747
+ async def clear_tamper_sensor(self, device_id: str) -> None:
1748
+ """Clears tamper status for sensor"""
1749
+ await self.api_request(f"sensors/{device_id}/clear-tamper-flag", method="post")
1750
+
1751
+ async def _get_versions_from_api(
1752
+ self,
1753
+ url: str,
1754
+ package: str = "unifi-protect",
1755
+ ) -> set[Version]:
1756
+ session = await self.get_session()
1757
+ versions: set[Version] = set()
1758
+
1759
+ try:
1760
+ async with session.get(url) as response:
1761
+ is_package = False
1762
+ for line in (await response.text()).split("\n"):
1763
+ if line.startswith("Package: "):
1764
+ is_package = False
1765
+ if line == f"Package: {package}":
1766
+ is_package = True
1767
+
1768
+ if is_package and line.startswith("Version: "):
1769
+ versions.add(Version(line.split(": ")[-1]))
1770
+ except (
1771
+ TimeoutError,
1772
+ asyncio.TimeoutError,
1773
+ asyncio.CancelledError,
1774
+ aiohttp.ServerDisconnectedError,
1775
+ client_exceptions.ClientError,
1776
+ ) as err:
1777
+ raise NvrError(f"Error packages from {url}: {err}") from err
1778
+
1779
+ return versions
1780
+
1781
+ async def get_release_versions(self) -> set[Version]:
1782
+ """Get all release versions for UniFi Protect"""
1783
+ versions: set[Version] = set()
1784
+ for url in PROTECT_APT_URLS:
1785
+ try:
1786
+ versions |= await self._get_versions_from_api(url)
1787
+ except NvrError:
1788
+ _LOGGER.warning("Failed to retrieve release versions from online.")
1789
+
1790
+ return versions
1791
+
1792
+ async def relative_move_ptz_camera(
1793
+ self,
1794
+ device_id: str,
1795
+ *,
1796
+ pan: float,
1797
+ tilt: float,
1798
+ pan_speed: int = 10,
1799
+ tilt_speed: int = 10,
1800
+ scale: int = 0,
1801
+ ) -> None:
1802
+ """
1803
+ Move PTZ Camera relatively.
1804
+
1805
+ Pan/tilt values vary from camera to camera, but for G4 PTZ:
1806
+ * Pan values range from 1 (0°) to 35200 (360°/0°).
1807
+ * Tilt values range from 1 (-20°) to 9777 (90°).
1808
+
1809
+ Relative positions cannot move more then 4095 units in either direction at a time.
1810
+
1811
+ Camera objects have ptz values in feature flags and the methods on them provide better
1812
+ control.
1813
+ """
1814
+ data = {
1815
+ "type": "relative",
1816
+ "payload": {
1817
+ "panPos": pan,
1818
+ "tiltPos": tilt,
1819
+ "panSpeed": pan_speed,
1820
+ "tiltSpeed": tilt_speed,
1821
+ "scale": scale,
1822
+ },
1823
+ }
1824
+
1825
+ await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
1826
+
1827
+ async def center_ptz_camera(
1828
+ self,
1829
+ device_id: str,
1830
+ *,
1831
+ x: int,
1832
+ y: int,
1833
+ z: int,
1834
+ ) -> None:
1835
+ """
1836
+ Center PTZ Camera on point in viewport.
1837
+
1838
+ x, y, z values range from 0 to 1000.
1839
+
1840
+ x, y are relative coords for the current viewport:
1841
+ * (0, 0) is top left
1842
+ * (500, 500) is the center
1843
+ * (1000, 1000) is the bottom right
1844
+
1845
+ z value is zoom, but since it is capped at 1000, probably better to use `ptz_zoom_camera`.
1846
+ """
1847
+ data = {
1848
+ "type": "center",
1849
+ "payload": {
1850
+ "x": x,
1851
+ "y": y,
1852
+ "z": z,
1853
+ },
1854
+ }
1855
+
1856
+ await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
1857
+
1858
+ async def zoom_ptz_camera(
1859
+ self,
1860
+ device_id: str,
1861
+ *,
1862
+ zoom: float,
1863
+ speed: int = 10,
1864
+ ) -> None:
1865
+ """
1866
+ Zoom PTZ Camera.
1867
+
1868
+ Zoom levels vary from camera to camera, but for G4 PTZ it goes from 0 (1x) to 2010 (22x).
1869
+
1870
+ Zoom speed does not seem to do much, if anything.
1871
+
1872
+ Camera objects have ptz values in feature flags and the methods on them provide better
1873
+ control.
1874
+ """
1875
+ data = {
1876
+ "type": "zoom",
1877
+ "payload": {
1878
+ "zoomPos": zoom,
1879
+ "zoomSpeed": speed,
1880
+ },
1881
+ }
1882
+
1883
+ await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
1884
+
1885
+ async def get_position_ptz_camera(self, device_id: str) -> PTZPosition:
1886
+ """Get current PTZ camera position."""
1887
+ pos = await self.api_request_obj(f"cameras/{device_id}/ptz/position")
1888
+ return PTZPosition(**pos)
1889
+
1890
+ async def goto_ptz_camera(self, device_id: str, *, slot: int = -1) -> None:
1891
+ """
1892
+ Goto PTZ slot position.
1893
+
1894
+ -1 is Home slot.
1895
+ """
1896
+ await self.api_request(f"cameras/{device_id}/ptz/goto/{slot}", method="post")
1897
+
1898
+ async def create_preset_ptz_camera(self, device_id: str, *, name: str) -> PTZPreset:
1899
+ """Create PTZ Preset for camera based on current camera settings."""
1900
+ preset = await self.api_request_obj(
1901
+ f"cameras/{device_id}/ptz/preset",
1902
+ method="post",
1903
+ json={"name": name},
1904
+ )
1905
+
1906
+ return PTZPreset(**preset)
1907
+
1908
+ async def get_presets_ptz_camera(self, device_id: str) -> list[PTZPreset]:
1909
+ """Get PTZ Presets for camera."""
1910
+ presets = await self.api_request(f"cameras/{device_id}/ptz/preset")
1911
+
1912
+ if not presets:
1913
+ return []
1914
+
1915
+ presets = cast(list[dict[str, Any]], presets)
1916
+ return [PTZPreset(**p) for p in presets]
1917
+
1918
+ async def delete_preset_ptz_camera(self, device_id: str, *, slot: int) -> None:
1919
+ """Delete PTZ preset for camera."""
1920
+ await self.api_request(
1921
+ f"cameras/{device_id}/ptz/preset/{slot}",
1922
+ method="delete",
1923
+ )
1924
+
1925
+ async def get_home_ptz_camera(self, device_id: str) -> PTZPreset:
1926
+ """Get PTZ home preset (-1)."""
1927
+ preset = await self.api_request_obj(f"cameras/{device_id}/ptz/home")
1928
+ return PTZPreset(**preset)
1929
+
1930
+ async def set_home_ptz_camera(self, device_id: str) -> PTZPreset:
1931
+ """Set PTZ home preset (-1) to current position."""
1932
+ preset = await self.api_request_obj(
1933
+ f"cameras/{device_id}/ptz/home",
1934
+ method="post",
1935
+ )
1936
+ return PTZPreset(**preset)